Co-browse without leaving the contact center: a Webfuse app inside Amazon Connect
A third-party app embedded in the Amazon Connect agent workspace that starts a Webfuse co-browsing session in one click - validated end-to-end in a live Connect instance, at roughly zero running cost.
Co-browse without leaving the contact center: a Webfuse app inside Amazon Connect
Built on Webfuse. It's a small app that lives inside the Amazon Connect agent workspace: the contact-center agent clicks one button and a Webfuse co-browsing session starts, right there in the panel, without leaving Connect. The customer joins by link or PIN. It's a reusable demo scaffold - one of several per-prospect Webfuse demos - built to prove a single claim: "use Webfuse from inside Amazon Connect." Nuxt 4, Vue 3, Tailwind v4, Bun, and the official @amazon-connect/app SDK.
The problem
Contact-center agents live inside Amazon Connect. The whole day happens there. So the naive ways to add co-browse both fail for the same reason - they pull the agent out of the tool they never leave:
- A separate tool or browser tab. A context switch mid-call, and the agent loses the customer and contact context Connect already holds.
- A custom CCP via amazon-connect-streams, rebuilding the agent desktop. Large surface, and AWS explicitly disallows initializing Streams inside a workspace app.
Underneath those, three root causes shaped the build - and each one is a place where guessing would have produced a plausible-but-wrong integration:
- The integration surface is an iframe inside the workspace. It only renders if the app's host explicitly permits Connect to frame it, and only if secrets never touch that browser context.
- Webfuse session creation is not where you'd guess. The obvious
POST /api/spaces/{id}/sessions/is GET-only. Sessions are activated at the space root instead. Assuming the RESTful-looking endpoint would have shipped a wrong integration that reads as right. - The platform moved under my knowledge cutoff. The service was renamed to "Amazon Connect Customer" in 2026 and the third-party-apps feature is region-gated. Setup guidance from memory would have been stale.
The design
An Agent Workspace third-party app - not a standalone tool, not a custom CCP. A 3p app is just a hosted web page the workspace frames and feeds context to. That framing decided almost everything.
The Nuxt server is the backend. Because a 3p app is a hosted web app, the Nuxt/Nitro server routes are a real backend - they hold the Webfuse keys and mint sessions server-side. I rejected a separate Lambda + API Gateway + Secrets Manager stack as overkill for a POC, and documented it as the production hardening path instead (Nitro deploys to Lambda natively, so it's a config change, not a rewrite). Secrets stay server-only via runtimeConfig; only the app name is exposed to the browser.
runtimeConfig: {
// Server-only (never shipped to the browser). Override via NUXT_WEBFUSE_* env.
webfuse: {
apiBase: 'https://webfuse.com/api',
spaceRestKey: '', // ${env.NUXT_WEBFUSE_SPACE_REST_KEY} - scoped to one Space
magicLinkKey: '', // ${env.NUXT_WEBFUSE_MAGIC_LINK_KEY} - signs the agent's join
mock: 'true', // mint fake sessions so the flow works without creds
},
public: { appName: 'Webfuse Co-browse' }, // only this reaches the browser
},
Make the app framable by Connect - the whole thing hinges on this. A browser only lets Connect frame the app if the app's own responses permit those parents via frame-ancestors. Without it the panel renders blank in the workspace. Note the asymmetry: this restricts who is allowed to frame us; it does not restrict what we frame. And I deliberately send no X-Frame-Options, because it would override the CSP and block framing outright.
routeRules: {
'/**': {
headers: {
'Content-Security-Policy':
'frame-ancestors https://*.my.connect.aws https://*.awsapps.com;',
},
},
},
Degrade gracefully when the host isn't there. The SDK only completes its handshake when the app is actually embedded in the workspace. In a plain browser tab - local dev, no live Connect instance - onCreate never fires. So a 4-second timer flips the app to a "standalone" mode that still runs and demos. This is what made local iteration possible without standing up a workspace for every change.
const standaloneTimer = setTimeout(() => {
if (!settled) state.status = 'standalone' // plain tab - still works
}, STANDALONE_AFTER_MS) // 4000ms
const { provider } = AmazonConnectApp.init({
onCreate: async (event: any) => {
settled = true
clearTimeout(standaloneTimer)
state.status = 'connected'
const scope = event?.context?.scope // Per-Contact scope carries the id
if (scope && 'contactId' in scope) state.contactId = scope.contactId ?? null
/* ... wire ContactClient.onConnected / AgentClient.onStateChanged ... */
},
onDestroy: async () => { state.status = 'standalone' },
})
Verify the real API surface instead of guessing it. This is where the "not where you'd guess" root cause got paid off. Webfuse doesn't create a session under /api/spaces/{id}/sessions/; it activates one by POSTing to the space root. Auth is Token, not Bearer. I confirmed both against the OpenAPI schema before writing a line.
const space = await getSpace()
const spaceRoot = space.link.replace(/\/+$/, '')
// "Creates a new session or retrieves the existing active one." (OpenAPI schema)
const activation = await $fetch(`${spaceRoot}/`, {
method: 'POST',
headers: { Authorization: `Token ${apiKey}` }, // Token, not Bearer
body: { metadata: { source: 'amazon-connect', /* contactId, customerName */ } },
})
// Agent joins as host via a signed magic link targeting this session.
const agentLink = magicLinkKey
? `${spaceRoot}/?magic_link=${signMagicLink()}&session_id=${activation.session_id}`
: activation.link
The co-browse view embeds inline - a nested iframe, not a new tab. The host view renders in an iframe inside the panel, so the agent never leaves Connect: workspace > our app > Webfuse. It works because each layer controls the right contract - our app's frame-ancestors says who can frame us, and Webfuse's Space frame_ancestors allows our origin to frame it. I kept an "open in new tab" escape hatch and a "couldn't embed" fallback behind the same 4-second pattern, because a nested embed is exactly the kind of thing a strict host policy can veto.
<iframe
:src="session.agentLink"
class="h-[70vh] w-full rounded-lg border border-line bg-white"
allow="camera; microphone; display-capture; clipboard-read; clipboard-write; fullscreen"
@load="onLoad" />
<!-- 4s timer -> "couldn't embed" fallback with an Open-in-new-tab button -->
Two smaller calls worth recording. Mock mode is the default (NUXT_WEBFUSE_MOCK=true returns fake sessions) so the whole flow demos with zero credentials. And Connect permissions are least-privilege: Contact.Details.View, Contact.Attributes.View, User.Status.View, with contact scope set to Per contact so the app receives the live contactId and nothing more.
Results
- Validated end-to-end in a real Connect Customer instance (EU Frankfurt / eu-central-1): app registered, appeared in the Agent Workspace launcher, handshook to a "Connected" badge, and started a real Webfuse session inline.
- ~$0 running cost for the POC - usage-based, no idle-instance fee, 500 free chat messages a month. The cost risks (a claimed phone number, a customer-managed KMS key) were deliberately avoided.
- ~685 lines of app/server code across 9 files, with the integration logic concentrated in two:
server/utils/webfuse.tsandapp/components/CoBrowsePanel.vue. Plus ~270 lines of setup docs. - The 3p-apps feature is live in 10 AWS regions (confirmed for EU Frankfurt and London).
bun run typecheckclean on pinned versions - Nuxt 4.4.8, Tailwind 4.3.2,@amazon-connect/*1.0.12, Bun 1.3.14. - Zero to a working in-workspace integration in about 30-45 minutes of build, ~1.5-2 hours end-to-end including the console setup.
The principle
An embedded platform integration is just a hosted web app plus the host's context SDK. The work is not clever code - it's honoring three contracts: keep secrets on your own server, satisfy the host's framing and CSP rules, and degrade gracefully when the host isn't there. Then verify the platform's real API surface instead of guessing it, because the obvious endpoint is often the wrong one.