24 KiB
PR4 Recon — Slice 4: Inline-in-Chat Install (Marketplace Variation B)
Scope: Screen 09 Marketplace, Variation B — Waggle offers a missing connector mid-conversation with a vault-aware approval ("token goes to your vault"), then shows the connected follow-up. Map the chat step/block rendering, the approval/confirmation infra, and the vault-write path; identify the exact hook points a PR4 build would touch.
READ-ONLY recon. No source modified. Citations are
file:lineagainstfeature/warm-hive-pr4(working tree at recon time).
1. The design contract (what Variation B must do)
From docs/design_handoff_waggle_app/SCREENS.md:199-207:
- Variation B (Inline in chat): "the same picker surfaced mid-conversation — Waggle offers a missing Salesforce connector with a vault-aware approval ('token goes to your vault'), then shows the connected follow-up."
- CRITICAL — shared install state ("sync"): "one store powers the grid, the agent picks,
AND the inline card." Type-aware one-click flows with progress→done micro-states:
- skill Add→Adding…→Added (instant)
- connector Connect→Signing in…→Connected (~1.1s, token→vault)
- MCP Enable→Enabling…→Enabled
- Each fires a toast + updates the count bar. Installing in any view reflects in all.
PR4 BUILD-PLAN row (docs/redesign-warm-hive/BUILD-PLAN.md:142):
PR4 | Marketplace + shared install store ("sync") (grid + agent-pick + inline-in-chat) | MarketplaceApp, new install store | 09
So Variation B is one of three consumers of a single shared install store. This slice maps the chat-side surface (how the agent raises the offer mid-turn, how the FE renders the inline card, and how approval → connect → vault wires). The grid + store itself is the broader PR4 build.
2. End-to-end map of the chat turn (what exists today)
2.1 SSE event protocol (server → FE)
POST /api/chat (packages/server/src/local/routes/chat.ts:351) is the SSE endpoint. It hijacks
the reply (chat.ts:462) and writes events with sendEvent(event, data) (chat.ts:475-477):
event: <name>\ndata: <JSON>\n\n
Event names emitted today (the wire vocabulary the FE switches on):
step, tool / tool_result (auto_recall only), token, gepa_choices, model_switch,
approval_required, done, error. (Note: the agent loop itself emits tool_start/tool_end
for real tool calls — see the FE switch in §2.3 — via the agent-loop callbacks, not direct
sendEvent in chat.ts.)
The approval handshake is the load-bearing primitive for Variation B. It lives in a
per-request pre:tool hook registered at chat.ts:909-1045:
- A tool reaches the gate;
needsConfirmationWithAutonomy(toolName, args, autonomyLevel)decides if it gates (chat.ts:916). - The hook enriches the request with risk metadata —
install_capabilitygets a content-basedassessTrust()(chat.ts:960-982), every other gated tool getsclassifyGatedToolRisk()(chat.ts:989-999).descriptioncomes fromdescribeToolUse(toolName, input)(chat-helpers.ts:106). - It emits
sendEvent('approval_required', { requestId, toolName, input, sourceWorkspaceId, ...trustMeta })(chat.ts:1005-1009). - It blocks on
new Promise<boolean>registered inserver.agentState.pendingApprovalskeyed byrequestId(chat.ts:1020-1036), with a 5-min auto-deny timeout. - The FE posts the decision to
POST /api/approval/:requestId(packages/server/src/local/routes/approval.ts:10), which callspending.resolve(approved)(approval.ts:31) → the hook returns{ cancel: true }on deny (chat.ts:1041) or lets the tool run on approve.
This approval handshake is exactly the mechanism Variation B needs — a server-side pause mid-turn that surfaces an FE card and resumes on the user's click.
2.2 Block model (the FE message content type)
apps/web/src/lib/types.ts:484-532 — ContentBlock is a discriminated union of:
TextContentBlock | StepContentBlock | ToolUseContentBlock | ModelSwitchContentBlock |
ErrorContentBlock. There is no approval/install block type — approvals are kept out of
the block stream (see §2.4) and rendered as a separate singleton.
StepContentBlock (types.ts:497-508) already carries an optional provenance: { sources }
field (PR3.5) — the precedent for type-specific metadata riding on a step block.
2.3 SSE → block reduction (useChat)
apps/web/src/hooks/useChat.ts:125-273 consumes the SSE stream and reduces events into
blocks[] on the last assistant message. The relevant cases:
step→ pushes aStepContentBlock(useChat.ts:152-175), copyingdata.provenance.sources.tool_start/tool_end→ push/patch aToolUseContentBlock(useChat.ts:177-222).approval_request/approval_required→setPendingApproval(data as ApprovalRequest)andreturn msgs— i.e. deliberately does NOT add a block (useChat.ts:258-263).approveAction(requestId, approved, { always })→adapter.respondApproval(...)thensetPendingApproval(null)(useChat.ts:317-333).
So today there is one pending approval at a time, held in component state, not in the block
stream. The hook returns { messages, isLoading, sendMessage, clearHistory, pendingApproval, approveAction } (useChat.ts:335).
2.4 Block rendering
apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx:53-104 walks blocks[]:
- consecutive
stepblocks are grouped into one collapsibleActivityStreamcard (BlockRenderer.tsx:30-51) — the "magic" surface. tool_useblocks route toArtifactBlock(completed file-writes —ArtifactBlock.tsx:21-28is the routing predicateisArtifactBlock) or the genericToolUseBlock.text/model_switch/erroreach have a renderer.
ArtifactBlock.tsx is the closest existing precedent for what an inline install card should be:
a tool-result that, when completed, renders as a rich actionable card (icon + name + button) instead
of a debug row. An inline-install card is the same pattern for a connector/skill/MCP tool result.
2.5 Approval rendering (the inline card that exists)
ChatApp renders the singleton approval inside the message list at the bottom:
{pendingApproval && <ApprovalGate request={pendingApproval} onRespond={onApprove} />}
(ChatApp.tsx:1213-1215).
ApprovalGate (ChatApp.tsx:225-311) is already an in-thread, warm-token card:
--honey-wash fill + attention border + AlertTriangle + a RiskBadge + description + a
mono toolName › inputSummary line + trustSource + Approve / Always-allow / Not-now buttons +
a "Show details" JSON toggle. It is generic (driven by ApprovalRequest), not connector-aware.
There is also a reusable warm primitive InlineApprovalCard
(apps/web/src/components/os/warm/InlineApprovalCard.tsx, exported from warm/index.ts:19) — a
cleaner card that takes an ApprovalRequest, title, onApprove/onDecline/onAlwaysAllow,
and an approveLabel. Its default title is literally "Approve before I leave your machine" —
already in the vault-aware register Variation B wants. NOTE: ApprovalGate (the one actually
wired in ChatApp) and InlineApprovalCard are two parallel implementations of the same idea;
both share the risk vocabulary via lib/risk-display.ts. A build should pick one.
ApprovalRequest shape (apps/web/src/lib/types.ts:410-430): { requestId, toolName, description, input, rawJson?, sourceWorkspaceId?, riskLevel?, approvalClass?, trustSource?, assessmentMode?, explanation?, permissions? }. All of these are already on the wire from chat.ts:1005-1009.
3. The install / connect / vault paths (what an "approve" must trigger)
There are three different install backends today, none of which is currently reachable as a single agent-loop "offer a missing connector" tool:
3.1 Skill install — agent-loop reachable (the only one that is)
packages/agent/src/skill-tools.ts:480-668 — the install_capability tool. It copies a curated
starter-skill .md into the skills dir (skill-tools.ts:631-633), runs a SecurityGate scan,
records install-audit rows, hot-reloads via onSkillsChanged(), and returns the skill content.
No vault. This is the "skill: Add→Adding…→Added (instant)" lane. It already flows through the
chat approval gate (it is in ALWAYS_CONFIRM, confirmation.ts:19) and gets the richest trust
metadata on the approval event (chat.ts:960-982).
3.2 Connector connect — HTTP route, NOT agent-loop reachable
POST /api/connectors/:id/connect (packages/server/src/local/routes/connectors.ts:96-156):
- validates the connector exists in
connectorRegistry, - requires
tokenorapiKeyin the body, - writes the token to vault:
fastify.vault.setConnectorCredential(id, { type, value, refreshToken, expiresAt, scopes })(connectors.ts:122-128) — this is the "token→vault" step the design names, - re-initializes the connector (
connector.connect(fastify.vault),connectors.ts:138), - records an install-audit row (
connectors.ts:145-153).
FE adapter: adapter.connectConnector(id, credentials) → POSTs that route
(apps/web/src/lib/adapter.ts:1971-1979).
There is no agent tool that calls this. The agent can only discover connectors via
find_connector / list_connector_categories (packages/agent/src/connector-search.ts:161-265),
which return catalog JSON (name, installCmd, url) as text — the agent literally cannot connect one.
3.3 Connector OAuth — separate browser-redirect flow
packages/server/src/local/routes/oauth.ts — GET /api/oauth/:provider/authorize →
provider page → GET /api/oauth/:provider/callback stores tokens in vault under
${provider}_oauth_token (keyed by provider, not connector id — see
connectors.ts:16-23 OAUTH_PROVIDER_FOR_CONNECTOR). Only 5 providers configured
(github/slack/google/notion/jira, oauth.ts:26-62) and each needs app client_id/client_secret
pre-seeded in vault. This is the heavyweight path; the design's "token goes to your vault" implies
the lightweight token-paste path of §3.2, not OAuth.
3.4 Connector tools become live on the NEXT turn
Important for "the connected follow-up": connector action tools are dynamic.
connectorRegistry.generateTools() generates connector_<id>_<action> tools only for connected
connectors (packages/server/src/local/index.ts:1005-1006 comment + 1034). The agent loop
rebuilds effectiveTools per request (chat.ts:1048-1073 → buildToolsForWorkspace). So once a
connector is connected mid-conversation, its tools appear on the next user turn (or next loop
iteration if connect happens inside the same turn before tool-pool rebuild — but the rebuild is
per-/api/chat call, so realistically next turn). describeToolUse already formats
connector_<id>_<action> as " via " (chat-helpers.ts:192-196).
3.5 MCP enable — yet another backend
MCP servers install through the MCP Hub (MCPHubApp.tsx, security scan + scope + approval). Not
chat-loop reachable today. Out of the critical path for Variation B's "Salesforce connector"
example, but the shared store must cover the "MCP Enable→Enabling…→Enabled" lane.
4. The DELTA — what's missing vs the Screen-09 contract
| # | Contract requirement | Current state | Gap |
|---|---|---|---|
| D1 | Agent can offer a missing connector mid-conversation | Agent can only find_connector (returns catalog text); no tool connects one |
No connect_capability/offer_connector tool that raises an approval whose approve-side writes a token to vault. Needs a new agent tool OR a server-side "offer" step. |
| D2 | Inline card is vault-aware ("token goes to your vault") | ApprovalGate/InlineApprovalCard are generic; show toolName › input + risk |
No connector-typed variant: no token-input field, no "encrypted in your local vault" copy, no Connect→Signing in…→Connected micro-states. |
| D3 | Token→vault on approve (~1.1s) | POST /api/connectors/:id/connect writes vault, but is reached only from MarketplaceApp/ConnectorsApp FE |
The chat approve path resolves a boolean promise (approval.ts:31); it has no channel to carry a token nor to invoke connectConnector. The approval contract is boolean-only. |
| D4 | Connected follow-up shown in thread | Connector tools regenerate per request; agent can use them next turn | No explicit "connected" confirmation block; the follow-up is implicit. Needs a success block/toast + (optionally) auto-continue of the original ask. |
| D5 | Shared install store ("sync") — one store, all 3 views reflect | MarketplaceApp uses local useState (installing/extensions, MarketplaceApp.tsx:108, :231); ConnectorsApp/MCPHubApp each own their own state |
No shared store. Installing in chat would not reflect in the grid or count bar. This is the central PR4 artifact ("new install store"). |
| D6 | Type-aware micro-states + toast + count-bar update | Skill install returns text; connector connect returns {connected:true}; toasts exist per-app (useToast) |
No unified progress→done state machine keyed by kind; no count bar; no cross-view toast. |
| D7 | Approval can carry kind (skill/connector/MCP) | approval_required carries toolName + trust meta, but kind is inferred from toolName |
A connector offer needs an explicit kind: 'connector' + connector id/name/why so the card renders type-aware. |
5. Exact integration points a PR4 build would touch
Server (agent loop + routes)
- New agent tool (e.g.
offer_connector/connect_capability) in a new file underpackages/agent/src/(sibling toconnector-search.ts), registered intobaseTools(packages/server/src/local/index.ts:759). It should be gated (add toALWAYS_CONFIRM,packages/agent/src/confirmation.ts:16-26, or rely onconnector_prefix patterns) so it hits the chat approval hook. chat.ts:909-1045pre:tool hook — extend the trust-metadata branch (currently special-casesinstall_capabilityatchat.ts:960) to emit a connector-typedapproval_requiredpayload (kind: 'connector', connectorid/name, "why", and a flag that a token field is needed).describeToolUse(chat-helpers.ts:106-201) — add a case for the new tool so the card's description line is specific ("Connect Salesforce — token stored in your local vault").- Approval contract widening —
POST /api/approval/:requestId(approval.ts:10) +pendingApprovalsresolve currently carry onlyboolean. To pass a pasted token from the inline card to the connect step, EITHER: (a) the card callsadapter.connectConnector(id, { token })directly (writing vault viaconnectors.ts:96) and then approves the tool with a boolean (token never transits the approval channel — cleanest, reuses existing vault route), OR (b) widen the approval body to carry the token and have the new tool'sexecutecallsetConnectorCredential. Option (a) is the lower-risk path and keeps the vault write on the audited/connectroute. - Vault write stays
fastify.vault.setConnectorCredential(id, …)(connectors.ts:122) — do not build a parallel secret store (CLAUDE.md §7.1).
FE (chat + store)
useChat.ts— theapproval_requiredcase (useChat.ts:258-263) sets a singletonpendingApproval. For Variation B the connector offer can stay on this channel (it IS an approval), but the payload must carrykind/connector fields so the renderer can branch. Alternatively introduce an install/offer block type inContentBlock(types.ts:484) +BlockRenderer(BlockRenderer.tsx) so the offer lives in the thread likeArtifactBlock, surviving history reload — recommended for the "connected follow-up" persistence.- New
ConnectorOfferCard(or extendInlineApprovalCard,warm/InlineApprovalCard.tsx) — type-aware card with a token field, "encrypted in your local vault" copy, and Connect→Signing in…→Connected micro-states. Render it fromChatAppwhereApprovalGaterenders today (ChatApp.tsx:1213-1215), branching onpendingApproval.kind. - Shared install store (the PR4 centerpiece,
BUILD-PLAN.md:142"new install store") — a React context/zustand store keyed by capability id with{ kind, state: idle|installing|done }, consumed byMarketplaceApp(replacing its localuseStateatMarketplaceApp.tsx:108/231),ConnectorsApp,MCPHubApp, AND the chat offer card. The card's approve handler callsadapter.connectConnectorand updates the store → grid + count bar reflect instantly. - Adapter — reuse
adapter.connectConnector(id, { token })(adapter.ts:1971),adapter.respondApproval(requestId, true)(adapter.ts:1757),adapter.installMarketplacePackage(skills/packages), and the connector listadapter.getConnectors()(for the count bar).
Recommended seam (lowest-risk wiring)
- New gated agent tool
offer_connector(id, why)→ emits connector-typedapproval_required(no token in the tool args; the token is collected by the FE card). - FE renders
ConnectorOfferCard; on Connect it (a)adapter.connectConnector(id, { token })→ vault write on the audited route, (b) updates the shared install store (grid/count-bar sync), (c)adapter.respondApproval(requestId, true)to release the agent. - The tool's
executereturns "Connected — Salesforce tools now available"; the agent usesconnector_<id>_<action>tools on the next turn (already live aftergenerateTools()). - A success block/toast renders the "connected follow-up".
6. Risks / sharp edges
- Boolean-only approval channel. The existing approval handshake resolves a
boolean(approval.ts:31). Threading a secret token through it would put a credential on the approval wire — prefer the FE-calls-/connect-directly seam (Option 5a) so the token stays on the dedicated vault route. - Two parallel inline-approval components (
ApprovalGateinChatApp.tsx:225vs warmInlineApprovalCard). OnlyApprovalGateis wired. Building a third card risks a 3-way drift; consolidate onto the warm primitive. - Approval is a singleton, out-of-band of blocks (
useChat.ts:262returns without pushing a block). It does not survive history reload and there is only one at a time. If the offer must persist in the transcript / show a permanent "connected" follow-up, it needs to become a realContentBlock(new type), which touches the block union,useChat,BlockRenderer, and history serialization. - Connector tools are next-turn, not same-turn. The "connected follow-up that actually uses the
connector" won't have the
connector_<id>_*tool in-loop on the same turn the connect happened (tool pool is built once per/api/chat,chat.ts:1048). A same-turn auto-continue would need an explicit re-dispatch. - No shared store today means a chat-side install silently diverges from the grid/count bar — the single most load-bearing PR4 requirement ("installing in any view reflects in all"). The store must land before any of the three surfaces is "done".
- MCP + skill lanes differ from connector. Skill install is agent-reachable + vault-free; connector connect is vault-bound + HTTP-only; MCP is Hub-only. A unified "type-aware one-click" card must dispatch to three different backends behind one store interface.
- OAuth vs token-paste. Some connectors (the Google family, jira, slack, github) are OAuth, not
token-paste (
oauth.ts:26-62). The vault-aware token field only fitsbearer/apiKeyconnectors; OAuth connectors need the redirect flow, which cannot complete inside an inline card without a popup/redirect. The card must branch onconnector.authType.
7. Open questions for the founder/lead
- Approval channel for tokens — keep the approval handshake boolean and have the FE call
/connectdirectly (audited vault route), or widen the approval body to carry the token? (Recommend the former.) - Offer as block vs singleton — should the inline connector offer (and its "connected"
follow-up) be a persistent
ContentBlockin the transcript, or stay the ephemeral singleton approval? (Persistence implies a new block type + history serialization.) - One inline card or two — consolidate
ApprovalGateandInlineApprovalCardinto the warm primitive before adding a connector variant? - OAuth connectors — for OAuth-only connectors (Google/Slack/etc.), does Variation B fall
back to "open the Connector Hub" or attempt an in-chat popup redirect? Token-paste only covers
bearer/apiKeyconnectors. - Same-turn vs next-turn follow-up — is "shows the connected follow-up" satisfied by a success toast + the tool being available next turn, or must the agent auto-continue and use the connector in the same turn (requires re-dispatch)?
- Shared store shape — new dedicated store, or extend an existing provider? It must be the single source the grid count bar, the agent-pick suggestion box, and this card all read/write.
- What raises the offer — a new gated agent tool the model calls when it hits a capability
gap, or a server-side heuristic (e.g. tool-not-found → CapabilityRouter,
chat.ts:1080) that injects the offer? TheCapabilityRouteralready exists for tool-not-found handling and could be the trigger.
8. Key files (quick index)
| Path | Role |
|---|---|
docs/design_handoff_waggle_app/SCREENS.md:189-207 |
Screen-09 contract (Variation A/B + sync) |
packages/server/src/local/routes/chat.ts:909-1045 |
pre:tool approval hook — emits approval_required, blocks on pendingApprovals |
packages/server/src/local/routes/chat.ts:475-477,1005-1009 |
sendEvent + the approval payload shape |
packages/server/src/local/routes/approval.ts:10-35 |
POST /api/approval/:requestId → resolves the boolean promise |
packages/server/src/local/routes/connectors.ts:96-156 |
POST /connect — token→vault (setConnectorCredential) |
packages/server/src/local/routes/oauth.ts |
OAuth redirect flow (heavyweight, 5 providers) |
packages/agent/src/connector-search.ts:161-265 |
find_connector / list_connector_categories (discovery only — no connect) |
packages/agent/src/skill-tools.ts:480-668 |
install_capability — the only agent-loop install (skills, vault-free) |
packages/agent/src/confirmation.ts:16-26 |
ALWAYS_CONFIRM gate set + needsConfirmation |
packages/server/src/local/index.ts:759,1005-1034 |
tool-pool assembly; connector tools generated for connected connectors only |
packages/server/src/local/routes/chat-helpers.ts:106-201 |
describeToolUse (approval description line) |
apps/web/src/hooks/useChat.ts:258-333 |
FE: approval_required → singleton pendingApproval; approveAction |
apps/web/src/components/os/apps/ChatApp.tsx:225-311,1213-1215 |
ApprovalGate inline card + its render slot |
apps/web/src/components/os/warm/InlineApprovalCard.tsx |
reusable warm inline-approval primitive ("Approve before I leave your machine") |
apps/web/src/components/os/apps/chat-blocks/ArtifactBlock.tsx |
precedent: tool-result → rich actionable in-thread card |
apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx |
block dispatch (where a new offer block would route) |
apps/web/src/lib/types.ts:410-430,484-532 |
ApprovalRequest + ContentBlock union |
apps/web/src/lib/adapter.ts:1757,1971 |
respondApproval, connectConnector |
apps/web/src/components/os/apps/MarketplaceApp.tsx:108,221-231 |
grid install — local useState (no shared store) |