SPA-aware Companion with Route-sensitive SurfaceContext
Overview
Keep the Companion widget alive across SPA route changes while ensuring each message carries the current page's surface_context. Uses setInputAugmenter to rewire context on navigation without destroying the widget or losing conversation history.
When to use
- Next.js / React Router / Remix apps where the URL changes without a full page reload
- Multi-page dashboards where the Companion should know which section the user is viewing
- Entity-detail pages (tickets, invoices, agents) where each navigation changes the entity context
The key insight
The React wrapper mounts the widget once (empty useEffect deps). Callbacks are stored in refs — they always call the latest version without remounting. When the route changes, a second useEffect calls setInputAugmenter to explicitly rewire the context function.
Pattern: global widget with per-route entity context
// components/GlobalCompanion.tsx
'use client';
import { useRef, useEffect } from 'react';
import { usePathname, useParams } from 'next/navigation';
import { CompanionWidget, CompanionWidgetRef } from '@human/companion-widget/react';
const DEPLOYMENT_ID = process.env.NEXT_PUBLIC_COMPANION_DEPLOYMENT_ID!;
function getSurfaceContext(
pathname: string,
params: Record<string, string | string[]>
) {
// Ticket detail page
if (pathname.startsWith('/support/tickets/') && params.ticketId) {
return {
surface: 'customer-support',
page: pathname,
entity_type: 'support_ticket',
entity_id: String(params.ticketId),
};
}
// Invoice detail page
if (pathname.startsWith('/billing/invoices/') && params.invoiceId) {
return {
surface: 'billing',
page: pathname,
entity_type: 'invoice',
entity_id: String(params.invoiceId),
};
}
// Default: just the page path
return {
surface: 'app',
page: pathname,
page_title: typeof document !== 'undefined' ? document.title : undefined,
};
}
export function GlobalCompanion() {
const pathname = usePathname();
const params = useParams() as Record<string, string | string[]>;
const widgetRef = useRef<CompanionWidgetRef>(null);
// Rewire surface_context on every route change — zero conversation loss
useEffect(() => {
widgetRef.current?.setInputAugmenter(() => ({
deployment_id: DEPLOYMENT_ID,
surface_context: getSurfaceContext(pathname, params),
}));
}, [pathname, params]);
return (
<CompanionWidget
ref={widgetRef}
humanApiUrl={process.env.NEXT_PUBLIC_API_URL}
agentsCallUrl="/api/companion/ask"
buildAgentInput={() => ({
deployment_id: DEPLOYMENT_ID,
surface_context: getSurfaceContext(pathname, params),
})}
ui={{ theme: 'dark', position: 'bottom-right' }}
/>
);
}
// app/layout.tsx
import { GlobalCompanion } from '@/components/GlobalCompanion';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<GlobalCompanion />
</body>
</html>
);
}
Pattern: entity name from data fetch
If you fetch the entity (e.g., ticket title) asynchronously, update the augmenter after the fetch:
// app/support/tickets/[ticketId]/page.tsx
'use client';
import { useRef, useEffect, useState } from 'react';
import { CompanionWidget, CompanionWidgetRef } from '@human/companion-widget/react';
export function TicketPage({ ticketId }: { ticketId: string }) {
const widgetRef = useRef<CompanionWidgetRef>(null);
const [ticket, setTicket] = useState<{ title: string } | null>(null);
// Fetch ticket
useEffect(() => {
fetch(`/api/tickets/${ticketId}`)
.then((r) => r.json())
.then(setTicket);
}, [ticketId]);
// Update augmenter when ticket title loads
useEffect(() => {
if (!ticket) return;
widgetRef.current?.setInputAugmenter(() => ({
deployment_id: 'dep_support',
surface_context: {
surface: 'customer-support',
page: `/support/tickets/${ticketId}`,
entity_type: 'support_ticket',
entity_id: ticketId,
entity_name: ticket.title, // Now includes the loaded title
},
}));
}, [ticket, ticketId]);
return (
<div>
<h1>{ticket?.title ?? 'Loading...'}</h1>
<CompanionWidget
ref={widgetRef}
humanApiUrl={process.env.NEXT_PUBLIC_API_URL}
agentsCallUrl="/api/companion/ask"
buildAgentInput={() => ({
deployment_id: 'dep_support',
surface_context: {
surface: 'customer-support',
page: `/support/tickets/${ticketId}`,
entity_type: 'support_ticket',
entity_id: ticketId,
// entity_name is not available yet — will be set by setInputAugmenter
},
})}
ui={{ theme: 'light', position: 'inline' }}
style={{ height: 480 }}
/>
</div>
);
}
Why setInputAugmenter and not just a prop change
If you passed buildAgentInput as a regular prop, changing it would not cause the widget to reinit (by design — the wrapper ignores prop changes after mount). You'd need a different mechanism to propagate the change.
setInputAugmenter is the imperative escape hatch: it updates the widget's internal augmenter directly, bypassing React's prop reconciliation entirely. The widget picks it up immediately for the next message send.