Skip to main content
Headless React hooks that read from the same wire the built-in chrome uses. Pair them with your own UI, or drop them beside VendoOverlay to power an inline widget.
Every hook here is also re-exported from @vendoai/vendo/react, which is the import that needs no direct @vendoai/ui dependency — with one exception. useApprovalModal ships only on @vendoai/ui/chrome:

The shared read shape

Collection hooks all return the same read fields, so one pattern renders loading, error, and empty states across the whole surface.
The collection is seeded, not undefined. So threads.length === 0 means empty or not loaded yet, and isLoading is what tells those apart. error is undefined, not null, when the read succeeded. A failed read keeps the last good collection, so you can render a retry affordance without blanking the surface. isLoading is true only for the first fetch. Later refresh() calls and background polls do not flip it back, so a first-mount spinner does not flash on every update. useApp is the exception. It returns a single document, so its app is AppDocument | undefined.

Every hook

There is no generic execute callback. Hooks that write name their verbs: decide, revoke, disconnect, enable, disable, create, remove, fork, importApp, edit, dryRun, stopRun. Each returns a promise, and the ones that change the collection refresh it when they resolve. exportApp is the exception, being a pure read that refreshes nothing. A hook’s isLoading still tracks only the first read, so drive mutation-pending UI from your own await.

Context accessors

There is no useVendo. useVendoProvider is the context accessor, because useVendoContext(data) owns the situation-channel name.

Polling

Pass pollMs to keep a value fresh without a manual refresh. Polls are self-scheduling rather than interval-driven, so the next tick arms only after the current refresh settles and a slow server never stacks requests.
Omit pollMs for a one-shot fetch on mount. Polling does not pause when the tab is hidden, so pick a cadence you are willing to pay for in the background. useApp and useVendoStatus never poll. useApp still returns refresh(); useVendoStatus reads once per mount, and remounting is the only way to re-read it. useSlotApp is the opposite: it polls every 5 seconds by default, because a placement made in the conversation surface has to appear in the slot on its own. Pass { pollMs } to change the cadence, or { enabled: false } to stand it down. useApprovals and useSlotApp each share one poller per client across every mounted instance, so a page with ten slots still makes one request.

Threads

useThreads reads the same summaries VendoOverlay uses, so a custom conversation list keeps parity with the shipped chrome.
ThreadSummary.title is always a string, so no ?? "Untitled" fallback is needed. Pair it with useVendoThread(threadId) to drive the streaming turn. It wraps the AI SDK’s useChat, so its vocabulary is the AI SDK’s.
The argument is named selectedThreadId on purpose. The hook also returns threadId, so destructuring into that name while passing it in is a use-before-declaration error. setMessages is what an edit-last affordance is built on: drop the last user turn and anything after it, then refill your input from that message. This is the flow the shipped chrome’s Edit affordance uses.
The hook does not queue sends. Calling sendMessage mid-stream hands the message straight to the AI SDK. The “type while it is answering, and it sends when the reply lands” behavior belongs to the shipped chrome’s composer, which holds the draft and re-sends it on the busy edge. Reproduce it by watching status.

Apps export and import

useApps exposes exportApp(appId) and importApp(bytes) beside the read fields, so a custom drawer can round-trip an AppDocument without hand-rolling calls to /apps/:id/export and /apps/import.
Import mints a fresh app_ id and carries over no data, grants, or authority.

Overlay control

useVendoOverlay gives your own chrome programmatic control over VendoOverlay.
useVendoOverlay(options?) accepts one option, defaultOpen?: boolean. Spread overlayProps onto the component and call open, close, or toggle from your own shortcut.
The panel portals to document.body, locks page scroll, marks the page behind the scrim inert, and restores focus to the invoking element on close. Closing hides the panel without discarding the conversation, so reopening within the same page session restores the prior messages. Call overlay.newConversation() to start fresh.

Approval modal

useApprovalModal is the mount seam for the screen-initiated approval modal, the centered ask a person sees when a button inside a generated view parks on the guard. The shipped chrome already mounts it on VendoSlot, on in-thread app cards and the workspace stage, on the chat embeds, and on mounted remix forks. Reach for the hook when you render a TreeView, AppFrame, or a bespoke slot yourself.
Wire it in two lines. Pass approval.onParked down to whichever component fires it, and render approval.modal alongside.
Presses queue by design. Pressing several guarded buttons raises several approvals, and exactly one modal is on screen at a time. Approve or Deny spends the decision. Esc and the scrim close the modal without deciding, so a dismissed ask stays pending.

refusalCopy

refusalCopy(reason) maps an error from approvals.decide to the same user-voice sentence the built-in approval card renders when a decision fails to land.

Hooks or chrome

Reach for VendoOverlay and the other chrome components when you want the shipped surface with brand tokens applied. Reach for hooks when you need counts, badges, or lists inside your own layout, or when your chrome has to react to Vendo state without rendering the overlay at all. Both paths speak the same wire, so mixing them in one app is safe. If you run your own agent loop and spread in the guarded tool pack, a separate set of components renders Vendo inside that chat instead: VendoToolResult, VendoAppEmbed, and VendoApprovalEmbed, all on Embeds and envelopes.