← Back to Patterns

SPA-aware Companion with Route-sensitive SurfaceContext

Availablecompanionintermediate

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 ) { // 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; const widgetRef = useRef(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 ( ({ 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 ( {children} ); }

    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(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 (

    {ticket?.title ?? 'Loading...'}

    ({ 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 }} />
    ); }

    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.

    See Also

  • Companion lifecycle hooks
  • Context-aware Companion with SurfaceContext
  • Embed Companion in proxy mode