Skip to content

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;
TermMeaning
rib idThe stable identifier, lowercase kebab-case, matching the package basename (@keelson/rib-demo has id demo). Gated by KEELSON_RIBS.
namespaceEverything a rib publishes lives under rib:<id> or rib:<id>:*. The harness rejects out-of-namespace keys at activation.
snapshot keyA namespaced key under which a rib publishes data. The browser renders the cached payload as a live board.
composerA function registered under a key that produces the payload on demand. A rib’s composeBundle is the composer for rib.id.
viewA static declaration that binds a snapshot key to a canvas renderer, so the harness draws it with no per-rib UI code.
surfaceA primary nav tab that lays out region-bound boards. Each region binds a namespaced key.
actionAn inbound verb a rib handles over the loopback API, so a board’s buttons can reach the rib.
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.

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.

AccessorReturnsNotes
getExec()RibExecProcess-exec with runJSON / runText. The one accessor always present.
getSnapshotManager?()SnapshotManagerRegister, 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?()stringAbsolute 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)RibAgentTurnRuns 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)() => voidAdds 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?()voidNudges 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?()MemoryToolsRecall 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)OpHandleRegisters 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> | unknownOpaque 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.

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.

  1. 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 workflow prompt node treats rib tools as default-off and runs one only when it names it in allowed_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, so demo_ping has family demo). A ToolDefinition is name, description, a zod inputSchema, optional advisory state_changing / requires_confirmation, and an execute(input, ctx) that returns nothing and emits results as tool_result chunks. Emit a tool_result with isError: true rather than letting a throw bubble through the SDK, and check ctx.abortSignal.aborted at every meaningful await.

  2. composeBundle(ctx) is registered as the snapshot composer under rib.id after registerTools returns. It is invoked lazily, only when something calls SnapshotManager.recompose(rib.id), never eagerly at boot. For a warm initial snapshot, call ctx.getSnapshotManager?.().recompose(rib.id) from inside registerTools. A rib can also register additional keys imperatively with ctx.getSnapshotManager?.().register(key, compose, opts).

  3. views are declarative RibViewDescriptors: { key, canvasKind, title? }. key must be namespaced. canvasKind is the closed enum markdown | view | html | log (html renders 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’s onAction with origin: "canvas-html" so the rib can gate them to a safe subset. The reply to a frame action may carry open-canvas or open-chat; the host drops any other effect. log renders 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 under key must 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 views per manifest request, so a rib may hold that array live and append to it at runtime, which is how a rib adds a per-subject html view, since the host resolves a key’s canvasKind by 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 call ctx.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.

  4. surfaces are declarative RibSurfaceDescriptors: a top-level nav tab with a layout of an optional header, an optional banner, zero or more rows of columns, and an optional footer. 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, with collapsed for the state they open in); a banner never does, and bannerRegionSchema omits those flags along with hideWhenEmpty, so a region that must fold belongs anywhere but there. Beyond id, title, and layout, the descriptor carries four optional flags: heading sets the page H1 independently of title (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; subtitle adds a header subtitle line; projectScoped opts the surface into the host’s shared ProjectChip, so on select the host dispatches the rib’s select-project action with the chosen project id and a per-project rib need not hand-roll a picker; and hideRegionActions opts 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). badgeKey names a snapshot key in the rib’s namespace holding { count, title? }; a count above zero shows as a pip on the surface’s tab, with title as its hover text. Each region is { key, workflow?, workflowArgs?, serverRefresh?, cadenceMs?, title?, glyph?, group?, groupTitle?, byline?, collapsible?, collapsed?, live?, hideWhenEmpty?, headActions? }: key is namespaced; workflow is the catalog workflow a region’s refresh re-runs; workflowArgs are 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: false explicitly opts a region out of the heartbeat without a warning, while an absent value keeps server refresh enabled; cadenceMs is 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; glyph is a { char, tone? } chip for the region head; group is a clustering hint used when adding regions at runtime via registerRegion; regions sharing a group string are kept contiguous in the surface rather than interleaved by arrival order; groupTitle is a section heading rendered for that group’s zone; byline is a context line shown beneath the region head’s title (e.g. a per-item subject); collapsible and collapsed control whether the region can be collapsed and its initial collapse state (honored on header, footer, and row-column regions; banner regions cannot collapse); live opts 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); hideWhenEmpty renders the region (head and body) only once its snapshot is live with content: a board that composes sections: [] 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’s onAction exactly like board actions. Destructive ones, and any that set confirm, ask first, the menu renders even when the surface sets hideRegionActions (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 affordances fields/expanded/inline/selected are 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’s workflow name hoists into a set that gates whether a refresh may run, and cadenceMs plus serverRefresh determine its heartbeat schedule. So a rib may mutate a declared region’s presentation and mount-defaults at runtime (collapsed, title, glyph, byline) and call ctx.invalidateManifest?.(), and the client picks them up on its next mount. Adding a region at runtime, or changing a workflow cadenceMs, or serverRefresh after boot does not work that way: /api/ribs would show it while its refresh is rejected as unbound and no heartbeat is ever scheduled. Add regions with registerRegion instead: it is the runtime path the workflow and heartbeat consumers actually track.

  5. contributeWorkflows(ctx) returns RibWorkflowContributions merged into the catalog at activation: { definition, bindSnapshotKey?, validate? }. definition stays unknown at the contract floor and the server validates it against the workflow schema when it merges. When bindSnapshotKey is set, the run’s structured output republishes to that key, and validate is 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 npm files). The package still needs its default-exported Rib object to be discoverable; only the workflows themselves need no code. Use the folder for fixed definitions; use contributeWorkflows when a definition is computed at activation or bound to a snapshot key, since binding needs the code-side validate. 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.

  6. contributeDocs(ctx) returns the RibDocsSources the rib adds to the harness docs catalog, collected once at activation like contributeWorkflows. Each source is { title, summary, llmsFullUrl?, content? }: either an llms-full.txt corpus the harness fetches and caches, or inline content. The keelson_docs tool 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.

  7. contributePolicies(ctx) returns the governance Policys the rib adds to the harness, collected once at activation like contributeWorkflows. Each Policy is { id, on?, evaluate(event, ctx) }; evaluate returns allow, deny with a reason, or ask, 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 the workflow surface the ctx also carries workflowName and nodeId, so a rib policy can scope a decision to its own workflow’s node rather than firing on every workflow-surface turn (which is all ctx.surface === "workflow" tells it). Both are absent on other surfaces and on an older harness that doesn’t populate them, where they read as undefined. A scope check such as ctx.workflowName === "x" then simply stops matching, so a policy that must stay correct against an older harness chooses its own absent-field fallback.

  8. onAction(action, ctx) handles inbound actions over POST /api/ribs/:id/action. action is { type, payload?, origin? }: type is a rib-defined verb the base never enumerates; payload stays opaque for the rib to narrow; origin is "board" | "canvas-html" and is stamped by the host, not the caller; a sandboxed canvas-html iframe cannot claim "board". Treat absent or "board" as a trusted host-UI dispatch; gate canvas-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-chat opens a fresh seeded conversation, run-workflow launches a catalog workflow (its optional stay flag 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), and open-canvas opens a snapshot with { effect: "open-canvas", key, title?, placement? }. Placement defaults to "center", the full reading sheet. Set "side" for a structured view document 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, and log documents always open centered, even when "side" is requested.

    open-surface switches the active surface tab. The open-surface shape is { effect: "open-surface", surfaceId, regionKey? }, where surfaceId is the browser tab id (surface:<ribId>:<surfaceId>) and regionKey optionally focuses one region after the tab switch. open-run opens a run that already exists in the same slide-over run drawer, approval composer included: { effect: "open-run", runId, workflow }, where workflow is the run’s workflow name. Unknown or malformed effects are rejected at the surface edge rather than treated as successful navigation.

    acceptsIngest?: boolean advertises that a rib handles the conventional ingest action. When set, chat can send a persisted message to that rib with { type: "ingest", payload: { text, sourceConversationId? } }. text is the message body, capped by ribIngestPayloadSchema; sourceConversationId identifies the conversation that produced it when one exists. Treat the payload as untrusted user data and narrow it at the rib boundary.

  9. 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 through ctx.startWorkflow, with startedByRibId set, so it can follow a catalog workflow it does not own. event is { workflowName, runId, status, inputs, startedAt, completedAt?, error?, pendingApproval?, startedByRibId? } with status one of running | paused | succeeded | failed | cancelled: one running at each launch and one terminal event when that launch settles. The rib that started the run also sees its pause transitions: paused each time the run stops on a human gate, carrying pendingApproval: { nodeId, prompt }, and running again 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 before paused existed never receives it. Delivery is once per launch, not once per run: resuming a failed or cancelled run emits a fresh pair with the same runId, and launches never interleave: a launch’s terminal event arrives before any later launch’s running, so the latest event is always current. inputs are the run’s inputs, so a rib can reconstruct dispatch context for runs it didn’t start; error carries 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).

  10. listAgents(ctx) / resolveAgent(slug, ctx) offer named chat agents, reusable turn templates of a system prompt plus an optional model, surfaced at GET /api/agents. listAgents is cheap and returns AgentSummarys; resolveAgent lazily builds one slug’s OpenChatSeed on selection and returns null for an unknown slug. A surface opens the seed as a fresh, seeded chat.

  11. listCommands(ctx) / invokeCommand(name, arg, ctx) / completeCommand(name, prefix, ctx) add slash commands to the chat composer, surfaced at GET /api/commands. listCommands returns static descriptors. invokeCommand is 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. completeCommand backs argument type-ahead and must return the full candidate set for an empty prefix.

  12. authStatus(ctx) is a credential probe surfaced in GET /api/ribs (and optionally doctor). 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> }.

  13. 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.

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 declared id diverges 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/ribs are 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.
RuleEnforced 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
  1. Discovery. bootstrapRibs() discovers installed @keelson/rib-* packages from node_modules/@keelson/ at boot. Embedders can bypass discovery by passing an explicit bootstrapRibs({ available }) map (the path tests use).

  2. 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.

  3. 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.

  4. Register. registerTools runs and its tools join the shared registry. composeBundle, if declared, is registered as the composer under rib.id (lazy). contributeWorkflows entries and any YAML files in the package’s workflows/ folder are merged into the catalog. views, surfaces, and authStatus become discoverable through GET /api/ribs.

  5. Run. Tools serve chat and workflow prompt nodes. The browser renders views and surfaces from cached snapshots and re-runs region workflows on their cadence. onAction handles board actions over the loopback API.

  6. Dispose. At shutdown the harness awaits each rib’s dispose before closing the database.

A rib never edits the SPA. The browser discovers active ribs through two endpoints, both typed in @keelson/shared:

  • GET /api/ribs returns ListRibsResponse ({ ribs: RibSummary[], crossRibGrants? }). Each RibSummary is { id, displayName, registered, views, surfaces, hasOnAction, acceptsIngest?, auth? }. views and surfaces are always present (possibly empty); auth appears only when the rib declares authStatus. Consumers treat absent acceptsIngest as false. crossRibGrants is 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/action returns RibActionResponse, the discriminated RibActionResult the rib’s onAction produced.
  • GET /api/agents returns ListAgentsResponse ({ agents: AgentRef[] }), the named chat agents ribs offer; POST /api/agents/:ribId/:slug/resolve resolves one slug to an OpenChatSeed.
  • GET /api/commands lists the rib slash commands the composer shows; /api/commands/:ribId/:name/complete and /api/commands/:ribId/:name/invoke back argument type-ahead and invocation.

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;

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;
  • 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 SnapshotManager interface and the canvas payload vocabulary a view must satisfy.