@get-asset/sdk
Chat
Headless compound for the owner-facing bookkeeping agent — it answers questions about the business’s books (P&L, cash flow, balance sheet, receivables and payables, ledgers, forecasts). It owns the chat lifecycle: creating the chat, sending a turn, streaming the reply token by token, and hydrating an earlier conversation. Conversation history lives server-side, so each turn sends only the new message. Replies come back as markdown, and may carry a source line and follow-up prompts — each exposed as its own part.
Anatomy
Import the primitive and compose the parts you need.
import { Chat } from "@get-asset/sdk";<Chat.Root><header><Chat.Back>Back</Chat.Back><Chat.Title /><Chat.Export>Download</Chat.Export><Chat.HistoryToggle>History</Chat.HistoryToggle></header><Chat.Loading>…skeleton…</Chat.Loading><Chat.Error><Chat.ErrorMessage /><Chat.Retry>Try again</Chat.Retry></Chat.Error><Chat.Empty><Chat.Greeting /><Chat.Subtitle /></Chat.Empty><Chat.Success><Chat.Messages><Chat.Message><Chat.UserMessage><Chat.MessageText /></Chat.UserMessage><Chat.AssistantMessage><Chat.MessageText /><Chat.MessageGraphs><Chat.Graph><Chat.GraphTitle /><Chat.GraphHighlight /><Chat.GraphDetail /><Chat.GraphExport>Download</Chat.GraphExport>{/* your chart, drawn from Chat.useChatGraph() */}<Chat.GraphSeries><Chat.GraphSeriesSwatch /><Chat.GraphSeriesLabel /></Chat.GraphSeries><Chat.GraphFootnote /><Chat.GraphGroups><Chat.GraphGroupLabel /><Chat.GraphValues><Chat.GraphValue /></Chat.GraphValues></Chat.GraphGroups></Chat.Graph></Chat.MessageGraphs><Chat.MessageSource /><Chat.MessageFollowUps><Chat.Suggestion><Chat.SuggestionLabel /></Chat.Suggestion></Chat.MessageFollowUps><Chat.MessageActions><Chat.CopyMessage>Copy</Chat.CopyMessage></Chat.MessageActions></Chat.AssistantMessage></Chat.Message></Chat.Messages><Chat.Thinking><Chat.ThinkingMessage /></Chat.Thinking><Chat.TurnError><Chat.TurnErrorMessage /><Chat.Retry>Try again</Chat.Retry></Chat.TurnError></Chat.Success><Chat.Form><Chat.Input /><Chat.Idle><Chat.Submit>Send</Chat.Submit></Chat.Idle><Chat.Busy><Chat.Stop>Stop</Chat.Stop></Chat.Busy></Chat.Form><Chat.Disclaimer /><Chat.History><Chat.HistoryHeading /><Chat.HistoryEmpty>No conversations yet.</Chat.HistoryEmpty><Chat.HistoryGroups><Chat.HistoryGroupLabel /><Chat.HistoryItems><Chat.HistoryItem><Chat.HistoryItemTitle /></Chat.HistoryItem></Chat.HistoryItems></Chat.HistoryGroups></Chat.History></Chat.Root>
Required scopes
The access token used by AssetProvider must include these scopes for this primitive to fetch data successfully:
chat:createchat:readchat:update
API reference
Root props
| Prop | Type | Default | Description |
|---|---|---|---|
chatId | string | — | An existing conversation (ach_…) to continue. Its transcript is fetched and rendered on mount, and swapping this for another id opens that one in place. Omit to start fresh — the chat is created lazily on the first message, so mounting the component never leaves an empty chat behind. |
prompt | string | — | Send a message supplied by another input. Each non-empty prompt is sent once; clear the prop before sending the same prompt again. |
greeting | string | "Good afternoon, {business}" | Heading shown above an empty transcript. Defaults to a time-of-day greeting addressed to the business, whose name is fetched. |
subtitle | string | — | Sentence under the greeting, surfaced by Subtitle. |
disclaimer | string | "Answers are generated from your books…" | Small print surfaced by Disclaimer. |
title | string | — | The conversation’s title, surfaced by Title. Defaults to the opening question, falling back to the matching history entry’s label. Pass a string to own it yourself, in which case renaming is up to you. |
history | ChatHistoryEntry[] | — | Past conversations for the history panel. Fetched from the API by default; pass an array to supply them yourself, or [] to skip the request entirely. |
defaultHistoryOpen | boolean | false | Whether the history panel starts open. |
onSelectChat | (chatId: string) => void | — | Called when a past conversation is picked. Picking one opens it in place, so this is a notification rather than a handler you have to implement — useful for mirroring the choice into a URL. |
onBack | () => void | — | Called when Backis used. Back already returns to the resting view on its own, so this is a notification — mirror it into a URL, or navigate elsewhere as well. |
onExport | (messages: ChatMessage[], title: string) => void | — | Called by Export. Defaults to downloading the transcript as a markdown file. |
onGraphExport | (graph: ChatGraph) => void | — | Called by GraphExport. Defaults to downloading the graph’s numbers as CSV. |
thinking | string | "Reading your books…" | Line shown while a turn is running but no reply text has arrived, surfaced by ThinkingMessage. |
placeholder | string | "Ask anything about your business…" | Placeholder applied to Input. |
resume | boolean | true | Rejoin a turn that was still streaming when the page reloaded, or one still running in a conversation opened from the history panel. Resume is best-effort and covers the current turn only — older turns come from the transcript. |
onChatCreated | (chatId: string) => void | — | Called with the new chat's id the first time a chat is created — persist it to reopen the conversation later. |
onFinish | (message: ChatMessage) => void | — | Called with the finished assistant message at the end of each turn. |
messages | ChatMessage[] | — | Override the transcript (skips the API call). |
state | "loading" | "error" | "empty" | "success" | "thinking" | "streaming" | "turn-error" | — | Force a specific view for testing/storybook. |
Parts
Transcript states
Mutually exclusive gates over the persisted conversation. Loading and Error cover hydrating a chatId; Empty and Success cover the transcript itself.
| Part | Description |
|---|---|
Chat.Loading | Renders while the persisted transcript is being fetched. |
Chat.Error | Renders when the transcript failed to load. |
Chat.ErrorMessageformat?: (error: Error | null) => ReactNode | Copy for a failed transcript load. |
Chat.Empty | Renders when the conversation hasn't started — pair with Greeting and Subtitle. |
Chat.Success | Renders once there are messages to show. |
Turn states
Gates over the turn in flight. Idle and Busy are complements — use them to swap Submit for Stop in the composer.
| Part | Description |
|---|---|
Chat.Thinking | Renders while a turn is running but no reply text has arrived yet. |
Chat.ThinkingMessage | The waiting line — the Root's `thinking` prop. |
Chat.Busy | Renders while a turn is running — submitted or streaming. |
Chat.Idle | Renders when no turn is running, so the composer accepts input. |
Chat.TurnError | Renders when the turn failed; pair with Retry to resend it. |
Chat.TurnErrorMessageformat?: (error: Error | null) => ReactNode | Copy for a failed turn. |
Messages
Iterates the transcript oldest-first, one context per message.
| Part | Description |
|---|---|
Chat.Messages | Iterator — renders its children once per message. |
Chat.Message | One bubble. Carries data-role="user" | "assistant" so the styled layer can align and colour the two sides from CSS alone. |
Chat.MessageText | The message's text, as markdown. Grows delta-by-delta while the reply streams. |
Chat.UserMessage | Renders its children only for a message the owner sent. |
Chat.AssistantMessage | Renders its children only for a reply from the agent. |
Reply anatomy
Everything the agent hangs off an answer. Each part renders nothing when the reply doesn't carry it, so a bare reply degrades to prose on its own.
| Part | Description |
|---|---|
Chat.MessageSource | Where the answer came from — "From your P&L and bank feeds · Books current through Jul 18". |
Chat.MessageFollowUps | Iterator over the follow-up prompts offered under a reply, providing a per-prompt context for Suggestion / SuggestionLabel. |
Data graphs
The graph cards a reply may carry. Scoped by MessageGraphs, which provides one graph context per card — the parts below read from it, and useChatGraph() hands the whole spec to whatever draws the chart.
| Part | Description |
|---|---|
Chat.MessageGraphs | Iterator — renders its children once per graph card, and nothing at all for the replies that carry none. |
Chat.Graph | The card itself. Carries data-graph-type ("bar" or "line") so a layout can switch on the chart it holds. |
Chat.GraphTitle | The comparison being drawn — "Q2 vs Q1 · 2025". |
Chat.GraphHighlight | The summary's emphasised lead — "Net profit up 48.3%." |
Chat.GraphDetail | The supporting sentence after the highlight. Renders nothing when the agent wrote none. |
Chat.GraphFootnote | The provenance line under the chart, when there is one. |
Chat.GraphExport | Downloads the graph's numbers — CSV by default, or whatever onGraphExport does. |
Chat.GraphSeries | Iterator over the series in render order — the legend, and a table's columns. |
Chat.GraphSeriesSwatch | The legend's colour chip. Carries data-series and data-series-index so it can be coloured from the same palette the chart uses. |
Chat.GraphSeriesLabel | The series' legend label — "Q1 (Jan–Mar)". |
Chat.GraphGroups | Iterator over the x-axis groups — a bar's categories or a line's points — one context each, for reading the numbers out beside the chart. |
Chat.GraphGroupLabel | The group's label — "Revenue" (bar) or "Jan" (line). |
Chat.GraphValues | Iterator over the current group's numbers, in series order — a group missing a series simply has one fewer. |
Chat.GraphValueformat?: (value: number) => ReactNode | One number, formatted for the graph's valueFormat. Carries data-series, data-series-index, and data-negative for a loss. |
Chat.GraphValueLabel | The label of the series this number belongs to. |
Reply actions
The copy control under a finished reply.
| Part | Description |
|---|---|
Chat.MessageActions | Gate — renders once a reply has finished, so the controls never appear against half an answer. Renders nothing for the owner's own messages. |
Chat.CopyMessage | Copies the reply to the clipboard as the markdown it arrived as, matching what Export writes to the downloaded file. Carries data-copied for a couple of seconds after. |
Header
The bar above the transcript. Every part renders nothing before the first message, so the resting view stays bare.
| Part | Description |
|---|---|
Chat.Title | The conversation's title. Renders nothing before the first message. |
Chat.Back | Returns to the resting view — a fresh conversation. Renders nothing while the resting view is already showing. |
Chat.Export | Downloads the transcript — markdown by default, or whatever onExport does. |
History panel
Past conversations, newest first, under headings that coarsen as they recede: Today, Yesterday, This week, Last week, This month, Last month, then a month and year. Fetched from the API unless the Root is given a history array.
| Part | Description |
|---|---|
Chat.History | Renders its children only while the panel is open. |
Chat.HistoryToggle | Shows/hides the panel. Carries aria-expanded. |
Chat.HistoryHeading | The panel's heading. |
Chat.HistoryEmpty | Renders its children when there are no past conversations. |
Chat.HistoryGroups | Iterator — renders its children once per bucket. |
Chat.HistoryGroupLabel | The bucket's heading — "Today", "Yesterday", "This week", "Last week", "This month", "Last month", then "March 2026" and older. |
Chat.HistoryItems | Iterator — renders its children once per conversation in the current bucket. |
Chat.HistoryItem | One past conversation — clicking it loads that conversation into the transcript. Carries data-current for the conversation on screen. |
Chat.HistoryItemTitle | The conversation's label. |
Composer
The chat box. Enter sends; Shift+Enter inserts a newline.
| Part | Description |
|---|---|
Chat.Form | A <form> whose submit sends the current input. |
Chat.Input | The text field. Renders a <textarea> wired to the chat's input state and the Root's placeholder. |
Chat.Submit | Sends the composer. Disabled until there's something to send and no turn is running. |
Chat.Stop | Aborts the in-flight turn, keeping whatever text already streamed in. |
Prompts
The follow-up prompts under a reply. Scoped by MessageFollowUps, which is the only thing that provides a suggestion context.
| Part | Description |
|---|---|
Chat.Suggestion | One clickable prompt — sends its label as the next message. |
Chat.SuggestionLabel | The prompt's text. |
Shared
| Part | Description |
|---|---|
Chat.Greeting | The greeting shown above an empty transcript. |
Chat.Subtitle | The sentence under the greeting. |
Chat.Disclaimer | Standing note that answers are generated and may be inaccurate. |
Chat.Retry | Re-runs whatever failed — the transcript load if that's what broke, otherwise the last turn. |
What a reply carries
MessageTextis markdown — paragraphs, bold section leads, bullet lists, and inline code, which is how figures arrive (`$63,880`). The SDK doesn’t render it; AssetChat uses the RichText primitive, and a bespoke layer can render it however it likes.
The source line comes from the reply’s message metadata, which lands as the turn finishes:
{"type": "message-metadata","messageMetadata": {"source": "From your P&L and bank feeds · Books current through Jul 18"}}
Follow-up prompts arrive separately, as a data-suggestionspart on the reply. Each string is both the prompt’s label and the message clicking it sends. A turn carries at most one such part, and may carry none:
{"type": "data-suggestions","id": "s1","data": {"suggestions": ["Is this month's spending normal?", "How did Q2 compare to Q1?"]}}
The part is persisted on the message, so a conversation reopened from history still offers its follow-ups. They surface as ChatSuggestions, rendered by Suggestion / SuggestionLabel— the same parts that render the starter prompts. Clicking one sends it as the next question.
Graphs
For a comparison question the agent may answer with a graph as well as prose. It arrives as a data-graphpart on the reply — live in the stream and persisted on the message, so a conversation reopened from history re-renders it — and surfaces as a ChatGraph on ChatMessage.graphs:
{"type": "data-graph","id": "g_1","data": {"type": "bar","title": "Q2 vs Q1 · 2025","summary": { "highlight": "Net profit up 48.3%.", "detail": "Revenue grew $22.5K…" },"series": [{ "key": "q1", "label": "Q1 (Jan–Mar)" }, { "key": "q2", "label": "Q2 (Apr–Jun)" }],"categories": [{ "category": "Revenue", "values": [{ "series": "q1", "value": 116000 },{ "series": "q2", "value": 138500 }] }],"value_format": "currency","currency": "USD","footnote": "Built from your income and spending for both quarters."}}
A data.type of linearrives the same way, differing only in its x-axis field — points (ordered, oldest first) in place of categories, each still { label, values }. Switch on graph.type to pick a bar or line chart.
The agent sends raw numbers and a valueFormat, never formatted strings — colours and formatting are the frontend’s. Everything a chart needs hangs off one hook, so the drawing itself stays yours:
function Graph() {const { graph, rows, groups, formatValue, formatCompactValue } = Chat.useChatGraph();// rows: [{ x: "Revenue", q1: 116000, q2: 138500 }, …] — x-axis label under `x`, one key per seriesconst Chart = graph.type === "line" ? LineChart : BarChart;return <Chart data={rows} series={graph.series} tickFormat={formatCompactValue} />;}
rowsis the x-axis groups pivoted for a charting library — each row’s label under the reserved x key. groupsis the same axis unpivoted (a bar’s categories or a line’s points, uniformly), for reading the numbers out. formatValue and formatCompactValuerender the graph’s numbers (“$116,000” and “$116K”) honouring its valueFormat and currency. Percentages arrive as whole numbers, so 18.7 means 18.7%, and any value may be negative.
bar and lineare the types today. A graph of any other type — or one missing its series or its x-axis groups — is dropped rather than rendered blank, and data parts the SDK doesn’t recognise are ignored, so a newer agent never breaks an older embed.
Forecasts
A forward-looking question may be answered with a forecast card — a data-forecast part, live in the stream and persisted like a graph, surfaced as a ChatForecast on ChatMessage.forecasts. It is its own part (never a data-graph) because a forecast splits booked history from projection and shades an uncertainty band. Two shapes, discriminated on type: a projection charts one scenario over time, a comparison charts several against each other.
{"type": "data-forecast","id": "f_1","data": {"type": "projection","forecast_id": "fct_1","scenario_id": "fsc_base","scenario_name": "Base case","title": "Cash through Sep 2026 · base case","summary": { "highlight": "Cash dips to $41K in October." },"metric": "closing_cash","currency": "USD","points": [{ "period": "2026-06", "actual": true, "value": 22100 },{ "period": "2026-07", "actual": false, "value": 15400,"band": { "p10": 9800, "p90": 21600 } }],"readiness": { "status": "needs_input", "confidence": "medium", "summary": "…", "gaps": [ … ] },"drivers": [ { "id": "drv_rev", "label": "Monthly revenue", "kind": "revenue","value": { "mode": "scalar", "value": 48000 }, "source": "derived" } ]}}
The chart reads the whole forecast off useChatForecast(): a projection splits its points at the actual boundary (solid history, dashed projection) and shades the band; a comparison draws a line per scenario over projected periods only (no history boundary — scenarios match over the past), the is_base one emphasised. Everything around the chart is a slottable part: ForecastTitle, ForecastHighlight, ForecastFootnote, the ForecastScenarioslegend, and — behind ForecastDetails / ForecastDetailsToggle — the readiness (ForecastConfidence, ForecastReadinessSummary, ForecastGaps) and driver assumptions (ForecastDrivers).
A gap is a question to the owner: ForecastGapPromptsends its question as the next user message, so the agent can fold the answer back into the forecast — distinct from data-suggestions, which are prompts fromthe user. A forecast the SDK can’t draw (an unknown type, a projection with no points) is dropped, so a newer agent never breaks an older embed.
Persisting conversations
Mount Root without a chatId to start a new conversation. The chat is created on the first message, not on mount, so a user who opens the panel and never types leaves nothing behind. Capture the id from onChatCreated and store it against the user to reopen the conversation later:
const [chatId, setChatId] = useState(savedChatId);<Chat.Root chatId={chatId} onChatCreated={setChatId}>…</Chat.Root>
Passing a chatId fetches the persisted transcript and renders it. If a turn was still streaming when the page reloaded, Root also rejoins that stream (see resume) so the reply finishes on screen rather than appearing only after a refresh.
Switching conversations needs nothing from you: clicking an entry in the history panel loads that transcript into the window, abandoning any turn in flight and clearing everything scoped to the old conversation (the draft message, the ratings). Swapping the chatId prop does the same, so a host driving the choice from a route works without remounting the Root behind a key.
Only one turn runs per chat at a time — posting a second message while one is in flight would reset the stream, so Submit disables itself for the duration and send() ignores the call.
Turns stream over the Vercel AI SDK’s DefaultChatTransport, which owns the UI message stream protocol. That is an implementation detail: Chat exposes flat ChatMessage objects, so nothing in your app needs to depend on ai or know the wire format.