@forwardpath/react
React components for ForwardPath intake. Wrap your app in
<ForwardPathProvider>, then drop in <FeedbackWidget /> for a floating
"Feedback" button, <FeedbackChat /> for an inline chat, or build your own UI
with useFeedback(). Built on @forwardpath/js.
Install
npm install @forwardpath/react @ai-sdk/react ai
react and react-dom (v19), @ai-sdk/react (v4) and ai (v7) are peer
dependencies. No Tailwind needed. Add @forwardpath/node on your server only
to verify users with userHash or to mint sessions from your own route.
Quick start
-
In the ForwardPath portal, create a publishable key for your project. Add each origin your app runs on (e.g.
https://app.acme.com,http://localhost:3000) and pick how to collect emails. Copy the key (fppk_…). -
Wrap your app in the provider and import the styles once.
// app/providers.tsx
"use client";
import { ForwardPathProvider } from "@forwardpath/react";
import "@forwardpath/react/styles.css";
type User = { id: string; email?: string | null; name?: string | null };
export function Providers({
user,
children,
}: {
user: User | null;
children: React.ReactNode;
}) {
return (
<ForwardPathProvider
baseUrl="https://app.forwardpath.ai"
publishableKey={process.env.NEXT_PUBLIC_FORWARDPATH_KEY!}
user={user}
appName="Acme"
appVersion={process.env.NEXT_PUBLIC_APP_VERSION}
environment={process.env.NODE_ENV}
>
{children}
</ForwardPathProvider>
);
}
- Add the widget to your layout, inside the provider.
// app/layout.tsx
import { FeedbackWidget } from "@forwardpath/react";
import { getUser } from "@/auth"; // your own auth
import { Providers } from "./providers";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getUser(); // null when signed out
return (
<html lang="en">
<body>
<Providers user={user}>
{children}
<FeedbackWidget />
</Providers>
</body>
</html>
);
}
Or render the chat inline on a page of its own:
// app/support/page.tsx
"use client";
import { FeedbackChat } from "@forwardpath/react";
export default function SupportPage() {
return (
<FeedbackChat
placeholder="What went wrong?"
onSubmitted={(report) => console.log("Filed", report.customerReference)}
/>
);
}
The publishable key is public
The publishable key is public by design. Anyone can read it from your page,
so it can only start intake chats for its one project, and only from the
origins allowed on the key. ForwardPath rate-limits it. Never put your secret
API key (fp_…) or the key's identity secret (fpiv_…) in browser code.
Who the reporter is
| You pass | Reporter | Meaning |
|---|---|---|
user and userHash | verified | Your server signed the user. |
user only | claimed | The browser named the user. Anyone can send any id or email, so treat it as unverified. |
| neither | anonymous | A visitor with no account. |
For an anonymous visitor the provider keeps a random id in localStorage
(forwardpath:anonymous-id), so repeat visits map to the same reporter.
Where storage is blocked, the id lasts for the page load.
When publishableKey, user.id, user.email or userHash changes, the
provider drops the cached session and mints a new one, so sign-in and
sign-out need no page load. A new user object with the same fields keeps
the session.
Verify users with userHash
Compute the hash on your server with createUserHash from
@forwardpath/node and the key's identity secret (fpiv_…, shown once when
you create the key). Pass it to the provider next to user.
// app/layout.tsx (a server component)
import { createUserHash } from "@forwardpath/node";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getUser();
const userHash = user
? createUserHash({
identitySecret: process.env.FORWARDPATH_IDENTITY_SECRET!,
user,
})
: undefined;
// Pass both on: <ForwardPathProvider user={user} userHash={userHash} …>
return (
<html lang="en">
<body>
<Providers user={user} userHash={userHash}>
{children}
<FeedbackWidget />
</Providers>
</body>
</html>
);
}
The hash signs user.id and user.email, not the name. Hash the same values
you pass as user: a mismatch fails the session with a 401. Without
userHash the user is claimed.
The email form
When the agent has no email for the reporter, e.g. an anonymous visitor or a
user without email, it shows a short form in the chat: Email, an optional
Name and Send. Once the user answers, the chat carries on by itself. While
the form is open, the composer is disabled.
Set contactCollection per key in the portal:
required: the agent files the report only after the user gives an email.optional: the form also shows Skip.off: the agent never asks.
The server saves the submitted email on the external reporter before the assistant continues. Filing links that reporter to the request and includes their contact details in the request metadata and Linear description. A typed email is marked as self-reported; it does not create a portal account.
If your app already knows the signed-in user's email, pass it in
user={{ id: user.id, email: user.email, name: user.name }} on
ForwardPathProvider. The chat uses it without asking again.
Verified users from your server
To mint sessions on your server with your secret API key instead, leave out
publishableKey and add a session route with @forwardpath/node. The
provider POSTs { clientContext } to /api/forwardpath/session by default
and expects { token, expiresAt } back. These reporters are always
verified.
// app/api/forwardpath/session/route.ts
import {
createForwardPathClient,
createIntakeSessionHandler,
} from "@forwardpath/node";
import { auth } from "@/auth";
const client = createForwardPathClient({
apiKey: process.env.FORWARDPATH_API_KEY!,
baseUrl: "https://app.forwardpath.ai",
});
export const POST = createIntakeSessionHandler({
client,
projectSlug: "acme-web",
// Return the signed-in user, or null to answer 401.
getUser: async () => {
const session = await auth();
if (!session?.user) return null;
return {
externalId: session.user.id,
email: session.user.email,
name: session.user.name ?? undefined,
};
},
});
<ForwardPathProvider baseUrl="https://app.forwardpath.ai" sessionKey={userId}>
Your secret API key can mint sessions for any user. Keep it on your server. The provider only ever sends the short-lived session token to ForwardPath.
<ForwardPathProvider>
| Prop | Description |
|---|---|
baseUrl | FPOS origin, e.g. https://app.forwardpath.ai. |
publishableKey | Publishable key (fppk_…). The browser mints sessions with it. |
user | { id, email?, name? } for the signed-in user, or null for an anonymous visitor. Used with publishableKey. |
userHash | createUserHash() output from your server. Makes user verified. |
sessionEndpoint | Your route that mints a session. Default /api/forwardpath/session. |
getSessionToken | Mint the session yourself. Gets { clientContext }; returns { token, expiresAt } or a token string. |
sessionKey | Who the session is for, e.g. the signed-in user's id. Changing it drops the cached session. |
appName, appVersion, environment, custom | Sent with each message so the team sees where feedback came from. |
fetch | Custom fetch for every request. |
getSessionToken wins over publishableKey, which wins over
sessionEndpoint.
The provider mints a token on first use and keeps it in memory. It mints a
new one 60 seconds before expiresAt, and once more if the API answers 401.
A bare string from getSessionToken is kept until a 401. Pass sessionKey so a
user switch without a page load doesn't reuse the old user's session.
useFeedback()
Returns { messages, sendMessage, status, error, stop, retry, reset, submitted, attachments, uploading, attachmentError, attach, removeAttachment, contactRequest, submitContact }.
sendMessage(text)sends a user message with any pending attachments. It does nothing whilecontactRequestis open.statusis the AI SDK chat status:ready,submitted,streamingorerror.retry()resends the last message after an error.reset()clears the chat.submittedis the latest successful submission ({ customerReference, title, ... }), ornull.attach(file)uploads aFileto send with the next message. See Attachments.attachmentslists the uploaded files waiting for the next message, andremoveAttachment(id)drops one.uploadingcounts uploads in flight;attachmentErrorsays why the last one failed.contactRequestis the agent's open request for an email ({ toolCallId, canSkip }), ornull. See The email form.submitContact(toolCallId, { email, name? })answers it, orsubmitContact(toolCallId, { skipped: true })whencanSkipis true. It trims values, drops an empty name and rejects an invalid email. The chat then carries on by itself.<FeedbackChat>renders this form for you.
Each useFeedback() call runs its own conversation. To drive the chat that
<FeedbackChat> or <FeedbackWidget> shows, pass your result as their
feedback prop.
import { useFeedback } from "@forwardpath/react";
function MyChat() {
const { messages, sendMessage, submitted } = useFeedback();
if (submitted) return <p>Thanks! Your reference is {submitted.customerReference}.</p>;
// ...
}
<FeedbackWidget />
A fixed launcher button that opens <FeedbackChat /> in a panel. Render it
once, inside the provider.
| Prop | Description |
|---|---|
position | bottom-right (default) or bottom-left. |
label | Launcher text and panel title. Default Feedback. |
title | Override the panel title independently of the launcher. |
launcherIcon | React node for the launcher mark. Defaults to assistantIcon, then a built-in chat mark. Pass null to hide. |
defaultOpen | Open the panel on mount. |
className | Added to the widget's root element. |
style | Inline styles and typed --fp-* theme variables on the widget root. |
It also takes every <FeedbackChat /> prop. className and style apply
to the widget root so the theme is inherited by the panel. Esc or the
close button closes the panel and returns focus to the launcher. Closing
hides the panel without ending the conversation.
<FeedbackChat />
| Prop | Description |
|---|---|
placeholder | Composer placeholder. |
assistantName | Display name on assistant messages and in the widget header. Default ForwardPath assistant. Does not change the model or its instructions. |
assistantIcon | Decorative React node, such as an SVG or an image with alt="". Used in the welcome state, assistant messages and widget header. Pass null to hide. |
welcomeTitle | Heading before the first message. Default How can we help?. |
welcomeMessage | Supporting text before the first message. Does not send a message or start a session. |
onSubmitted(report) | Called once per successful submission. |
className | Added to the root element. |
style | Inline styles and typed --fp-* theme variables. |
feedback | A useFeedback() result to render instead of the chat's own. |
Messages render as plain text with line breaks. HTML in a message shows as text and never runs. When the agent asks for an email, the form shows inside its message.
Attachments
Users can attach files with the paperclip button or by dropping them on the chat. Each file uploads at once and shows as a chip above the composer until the next message sends it. Image attachments are shown to the agent, and all attachments can be added to the filed request.
Before uploading, the chat checks that each file:
- is under 10 MB (the upload request, framing included, must fit in 10 MB),
- is a PNG, JPEG, WebP or GIF image, PDF, plain text, CSV or JSON file (or
has no type, which uploads as
application/octet-stream), and - doesn't take the conversation past 10 attachments.
To attach a file from your own code, e.g. a log export, call attach:
"use client";
import { FeedbackWidget, useFeedback } from "@forwardpath/react";
export function Feedback({ getLogs }: { getLogs: () => string }) {
const feedback = useFeedback();
async function attachLogs() {
await feedback.attach(
new File([getLogs()], "app-log.txt", { type: "text/plain" }),
);
}
return (
<>
<button onClick={attachLogs}>Attach logs</button>
<FeedbackWidget feedback={feedback} />
</>
);
}
attach rejects if a check or the upload fails, and sets attachmentError.
Screen capture
The "Capture screen" button asks the browser to share a screen, window or
tab with getDisplayMedia, saves one frame as screenshot-<ISO time>.png,
stops sharing and attaches the image. Nothing else is recorded.
- The browser prompts the user each time. Cancelling the prompt does nothing.
- The button only shows where
navigator.mediaDevices.getDisplayMediaexists: secure contexts (HTTPS orlocalhost) in desktop browsers. Most mobile browsers don't support it. - In an iframe, the parent page must allow it:
<iframe allow="display-capture">. APermissions-Policyheader that blocksdisplay-capturedisables it too.
Theming
Brand each implementation without replacing the chat UI:
<FeedbackWidget
label="Help"
title="Acme support"
assistantName="Acme assistant"
assistantIcon={<img src="/acme-mark.svg" alt="" />}
welcomeTitle="How can we help?"
welcomeMessage="Tell us what's happening or what you'd like to improve."
style={{
"--fp-accent": "#0f766e",
"--fp-accent-foreground": "#ffffff",
"--fp-radius": "16px",
"--fp-panel-width": "420px",
}}
/>
The launcher uses the same mark unless you supply launcherIcon. All
icons are decorative; label, title and assistantName provide the
accessible text. Choose an accent and foreground pair with readable contrast
in your app's light and dark themes. FeedbackStyle is exported for sharing
typed styles between widget and inline chat instances.
Every class starts with fp-. Set these variables on :root, an ancestor
or .fp-root:
:root {
--fp-accent: #0f766e;
--fp-accent-foreground: #ffffff;
--fp-bg: #ffffff;
--fp-fg: #111827;
--fp-radius: 8px;
--fp-font: "Inter", sans-serif;
--fp-z-index: 1000; /* widget stacking order */
--fp-panel-width: 400px;
--fp-panel-height: 620px;
}
Add data-fp-theme="dark" to an ancestor (e.g. <html>) for dark
defaults. If you set --fp-bg or --fp-fg yourself, also set them under
your dark-mode selector.