This document describes how Corbits Workbench is structured, independent of specific technology choices — see IMPLEMENTATION.md for the concrete stack.
| Layer | What lives here |
|---|---|
apps/ |
Deployable services: hub (API), sidecar (execution host), web (UI) |
packages/ |
Domain packages — where product rules live |
workflows/ |
Workflow definition packages, deployed as assets |
vendor/intx |
Hand-copied, ledgered @intx/* source — see VENDORED.md |
Packages own product rules. Apps stay generic — they compose packages
rather than own domain logic — with one tracked exception: apps/hub
carries a small, explicitly-listed set of mounts (artifacts-mount.ts,
memory-mount.ts, skills-mount.ts, slack-tag-mount.ts,
tenant-create-guard.ts) pending extraction into packages (tracked as
CL-6127). Outside that list, a product rule that lives inside apps/* is
a defect — it belongs in a package.
Everything about identity, credentials, agent execution, and workflow
orchestration belongs to Interchange (@intx/*), never reimplemented in
this repo:
- Tenancy — the tenant hierarchy, membership, principals, roles, and
grants (
@intx/db,@intx/hub-api). - Credentials — resolution and storage.
- Agents — Interchange owns launch (
prepareProvisionedDeployment), the run principal, and session primitives (@intx/agent,@intx/hub-agent,@intx/hub-sessions). - Inference — LLM calls (
@intx/inference). - Workflow runtime — definitions, runs, and the workflow host
(
@intx/workflow,@intx/workflow-host,@intx/workflow-deploy). - Mail — the transport an agent turn uses to receive and send messages. A workbench itself is not a mail-holding host run.
Workbench adds product contracts on top of these primitives; it never forks or patches Interchange internals. See docs/TENANCY.md for the authoritative list of what Interchange already provides versus what is a genuine upstream gap workbench has had to work around product-side.
Workbench is a client of Interchange. Launching an agent is Interchange
prepareProvisionedDeployment onto that instance's own kind:workflow
asset (default ref) in the hub git store. Interchange mints the run
row, the allocation, and — on first trigger — the run principal. Chat
records the returned run id, deployment id, and mail address. It does
not pre-mint an anchor run, own an agent_session, or render a
per-run sourceRef. @corbits/folded-runs and
@corbits/folded-run-one-shot are gone. A one-shot wait-loop for Myra
drafting lives in @corbits/agent-directory as runOneShotPrompt and
still calls prepareProvisionedDeployment.
Launch is asynchronous. The sidecar reconciler's ready hook and the
workflowDispatchService queue (wired in apps/hub) deliver a
message sent before the instance is ready; the UI shows a starting
state until then. Presence while starting comes from allocation and
deployment status, not a run principal. Wake of a reaped or lost
instance is a fresh prepareProvisionedDeployment onto the same
asset; chat updates the recorded ids.
A turn is one native sendUserMessage per addressed agent, carrying
Message-ID, In-Reply-To, and References. Agent replies reach humans
through the hub persistMail wrapper into @corbits/mailbox. Turns
do not use a correlationId. Human attachments are read from the
mailbox frame, not Interchange session_mail. Inference sources ride
as catalog offering ids (sourceOfferingIds /
defaultSourceOfferingId). Catalog instantiate does not 409
credential-binding workflows.
A bench (the shared team scope) and a workbench (a single conversation) are both Interchange tenants underneath — a bench is simply a tenant nothing else is parented under as a workbench, in practice the one a person signs into. A workbench mints its own child tenant at creation time, parented under the bench, so its membership and grants are native and independent of the bench's own.
Parent tenancy is largely invisible plumbing to a person using the product: they experience "the bench" and "the workbench they're in," not tenant ids or parent chains. Underneath, inheritance is live — plugins, credentials, and catalog data resolve by walking the ancestor chain on every read, never copied down at creation time. A sub-workbench or child tenant never gets a snapshot of its parent's catalog; it always sees the parent's current state.
The workbench host's default inference model is chosen from that visible catalog: tenant-owned (direct) completion offerings beat inherited catalog seeds when any exist; an inherit-only catalog still picks the inherited completion default. Embedding models stay ineligible. This is a preference among what the ancestor walk already shows, not a snapshot and not a second catalog.
See docs/TENANCY.md for the full contract, including
the workbench-owned discriminator that distinguishes a "real" bench from a
workbench's own child tenancy (native tenants carry no kind field), and
docs/GLOSSARY.md for the term mapping between product
nouns and platform primitives.
A workbench is not a parallel messaging system bolted onto Interchange,
and it is not itself a workflow run. Creating a workbench mints a child
tenant and writes settings rows — no deploy, no host, no anchor instance
(provisionSpaceWorkbench and the chat create path). A workbench holds a
timeline of chat.workbench_messages rows; posting is one insert plus
one publish onto the workbench's live stream, so the conversation takes
messages whether or not any agent process is running.
Editing an own prompt. Edit is a composer replace, not a timeline
mutation and not a thread fork. The timeline offers Edit only on the
signed-in reader's own prompts that have text
(packages/chat-ui/src/timeline.tsx). Choosing it copies that message's
text into the composer through ComposerHandle.setText
(packages/chat-ui/src/composer.tsx). setText replaces the draft and
clears leftover composer-private state (slash, mention, pending invites,
attachments, in-flight attachment reads). Sending is the ordinary post
onto the timeline already in view (root or an open thread). It does not
PATCH the origin message and it does not call forkThread.
A workbench's address is derived, not resolved. Asking an invited agent
for a turn is a separate act over Interchange mail; the agent replies by
emitting connector.reply events on its own stream, and a reply bridge
turns those events into timeline messages. See
docs/CHAT.md for the full message, thread, and
participant model. Per-workbench settings (name, participants, capacity,
connector overrides, notification prefs — see PRODUCT.md's "Workbench
settings") are composition on top of this tenant and its settings rows,
not a separate object; the surface lives in packages/chat-ui's
workbench-settings.
In-room onboarding scene. A named template's first-minute walkthrough is one timeline card posted in the room's own voice, not a member's message and not a side effect of hosting an agent. The card keeps a stable header — the job, the promise, the ordered steps — and flips its body in place through connect, pick-repos, and reviewing. Change-repos returns the body to the picker without rewriting what the room already recorded. An empty step list is omitted, not rendered as an empty rail. The current step is named in words; colour is additive, never the only signal. Consecutive agent-joined events collapse into one line so the scene and the reviewers' own introductions are what a person reads first.
A kind: chat is 1:1. It is the one DM with its agent. Inviting a
different or additional agent into that conversation is a conflict
(HTTP 409 kind_is_chat); extra agents belong on a kind: workbench
channel. A same-definition invite reuses the resident principal and
does not clone a sibling instance.
Default specialist creation mints or reopens that DM. Myra's
create_agent path does not invite the new definition into the
caller's conversation — Myra's DM is itself kind: chat and would
reject the extra agent. It mints a kind: chat for the definition
under the bench, or reopens the existing one for that (bench,
definition) pair, matching the product-surface find-or-reopen rule.
The specialist launches into that chat, never into Myra's.
Streaming a reply. An agent's live reply reaches the timeline through one path, deltas to pixels:
- Inference emits
inference.text.deltaevents (vendored,@intx/inference) as an agent generates a reply, each carrying the cumulative text so far. - The sidecar's per-agent event stream (
SidecarRouter.subscribeAgent,vendor/intx/hub-sessions/src/ws/sidecar-handler.ts) carries those events out of the run. packages/chat/src/platform-adapter.tssubscribes to that stream and wraps each event as achat.agentpayload;packages/chat/src/workbench-events.tsmerges it with in-process ephemeral events (typing, settings changes) onto one SSE stream, served fromGET /workbenches/:id/stream(packages/chat/src/routes.ts), re-checking access on every delivered event.- On the web,
packages/chat-ui/src/use-workbench-stream.tsholds theEventSource(with backoff reconnect and a polling fallback), andpackages/chat-ui/src/streaming-reply.tsnarrowschat.agentpayloads toinference.text.deltaand reduces them into one growing string. packages/chat-ui/src/timeline.tsxrenders that growing string as a synthetic in-progress message alongside the persisted timeline, until the real message lands and replaces it.
Provider tool-call quirks. Some OpenAI-compatible local models emit a declared tool call as a JSON object in assistant text instead of a native tool-call field. Workbench's Ollama adapter wraps the platform OpenAI adapter (composition, not a fork) and reclassifies that exact object into structured tool-call events when the tool name was declared for the request. Unknown names, extra keys, arrays, mixed prose, and fenced JSON stay text so a legitimate JSON or code answer is not stolen. The reactor and timeline still only see the usual text versus tool-call events.
A picker "template" is a shipped Workbench Definition, not a second kind of object: default agents, routines, tools, required and optional plugins, and an ordered onboarding walkthrough. Creating from a named row mints an empty workbench channel with no host, then instantiates that definition into the room. The walkthrough is posted as a system timeline card, never as a side effect of hosting an agent — so an empty channel can onboard with nobody launched.
Code review's definition names three reviewers and does not name Myra. GitHub already connected is the same in-room card: it reads live credential state and flips to repository pick. There is no separate create-dialog path for the already-connected case.
Settling a connector a template room is waiting on records the connected event from a system address and does not wake an agent. A generic in-room connect that an agent asked for still wakes that agent.
Credential bindings for pinned tool packages fold only at deploy. Connecting a connector that feeds those packages relaunches live assistants that already pin them, so the new credential is on the run — not only on the next invite. The pending-room settle and that relaunch are separate passes.
An agent's capability set grows through what it is granted, not through
code changes: Skills are installable, tenant- or principal-scoped
capabilities layered onto a definition or a workbench, resolved live
rather than baked in at deploy time (see packages/skills). Plugins are
installed globally rather than per bench or workbench (CL-6272.2). Every
external side effect — anything leaving the
platform's own boundary — sits behind a human approval gate. Approval
itself is native Interchange state (an approval row backed by a
signal_correlation row); the web client composes the names on top of it
(which agent, which bench) from the native pending-approvals list and run
view, and @corbits/approvals supplies the grant-allowance gate and the
human-readable headline, so a person can read what they're being asked to
approve — see docs/needs-you.md.
The sidecar is the execution host that runs workflow definitions on behalf of a hub. It is a native Interchange subsystem, not a workbench-specific service: workbench composes it the way it composes everything else on the platform, supplying placement policy (which sidecar a given run lands on) rather than reimplementing execution. Provisioning and allocation follow Interchange's own contracts; workbench does not maintain a parallel scheduler.
Remote MCP servers connect through Plugins (curated presets and
add-by-URL). The connect-time probe and later tool calls share one
origin-pinned fetch: every first hop must be the stored origin or an
explicit extra origin for that pin — not a host-suffix match — and a 3xx
is never followed, even to an allowlisted origin. Canva is the one
shipped extra: the stored MCP origin may also first-hop the protocol
origin that is not the stored apiBaseUrl.
OAuth presets that list advertised scopes send those scopes on RFC 7591
dynamic client registration; presets that omit the list stay on the SDK's
protected-resource metadata fallback. When /start fails, the return
distinguishes client_rejected (the authorization server refused
Workbench as a client — including RFC 7591 invalid_redirect_uri and
sibling client-metadata codes, even when the SDK maps an unknown code
onto a generic server error) from discovery_failed (the authorization
server could not be reached). A successful callback re-probes with the
new token and may put that probe's tool count on the Plugins return so
the row can show it.
Live Canva OAuth against Canva's own servers is not verified; this is the shipped control flow, not a proven live handshake.
- docs/GLOSSARY.md — product-term to platform-term mapping
- docs/TENANCY.md — tenancy contracts and Interchange gaps
- docs/CHAT.md — the conversation/message/thread model
- docs/workbench-tenancy.md — workbench tenant mint, listing, and move mechanics
- docs/needs-you.md — the approval surfacing model
- docs/connect-cards.md — in-room connect cards and template-room settle
- VENDORED.md — the vendoring ledger for
@intx/*
- Sidecar placement policy specifics (how a run is assigned to a particular sidecar instance under multi-sidecar deployment) are not detailed in the docs reviewed for this pass.