@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:create
  • chat:read
  • chat:update

API reference

Root props

PropTypeDefaultDescription
chatIdstringAn 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.
promptstringSend a message supplied by another input. Each non-empty prompt is sent once; clear the prop before sending the same prompt again.
greetingstring"Good afternoon, {business}"Heading shown above an empty transcript. Defaults to a time-of-day greeting addressed to the business, whose name is fetched.
subtitlestringSentence under the greeting, surfaced by Subtitle.
disclaimerstring"Answers are generated from your books…"Small print surfaced by Disclaimer.
titlestringThe 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.
historyChatHistoryEntry[]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.
defaultHistoryOpenbooleanfalseWhether the history panel starts open.
onSelectChat(chatId: string) => voidCalled 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() => voidCalled 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) => voidCalled by Export. Defaults to downloading the transcript as a markdown file.
onGraphExport(graph: ChatGraph) => voidCalled by GraphExport. Defaults to downloading the graph’s numbers as CSV.
thinkingstring"Reading your books…"Line shown while a turn is running but no reply text has arrived, surfaced by ThinkingMessage.
placeholderstring"Ask anything about your business…"Placeholder applied to Input.
resumebooleantrueRejoin 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) => voidCalled with the new chat's id the first time a chat is created — persist it to reopen the conversation later.
onFinish(message: ChatMessage) => voidCalled with the finished assistant message at the end of each turn.
messagesChatMessage[]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.

PartDescription
Chat.LoadingRenders while the persisted transcript is being fetched.
Chat.ErrorRenders when the transcript failed to load.
Chat.ErrorMessage
format?: (error: Error | null) => ReactNode
Copy for a failed transcript load.
Chat.EmptyRenders when the conversation hasn't started — pair with Greeting and Subtitle.
Chat.SuccessRenders 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.

PartDescription
Chat.ThinkingRenders while a turn is running but no reply text has arrived yet.
Chat.ThinkingMessageThe waiting line — the Root's `thinking` prop.
Chat.BusyRenders while a turn is running — submitted or streaming.
Chat.IdleRenders when no turn is running, so the composer accepts input.
Chat.TurnErrorRenders when the turn failed; pair with Retry to resend it.
Chat.TurnErrorMessage
format?: (error: Error | null) => ReactNode
Copy for a failed turn.

Messages

Iterates the transcript oldest-first, one context per message.

PartDescription
Chat.MessagesIterator — renders its children once per message.
Chat.MessageOne bubble. Carries data-role="user" | "assistant" so the styled layer can align and colour the two sides from CSS alone.
Chat.MessageTextThe message's text, as markdown. Grows delta-by-delta while the reply streams.
Chat.UserMessageRenders its children only for a message the owner sent.
Chat.AssistantMessageRenders 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.

PartDescription
Chat.MessageSourceWhere the answer came from — "From your P&L and bank feeds · Books current through Jul 18".
Chat.MessageFollowUpsIterator 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.

PartDescription
Chat.MessageGraphsIterator — renders its children once per graph card, and nothing at all for the replies that carry none.
Chat.GraphThe card itself. Carries data-graph-type ("bar" or "line") so a layout can switch on the chart it holds.
Chat.GraphTitleThe comparison being drawn — "Q2 vs Q1 · 2025".
Chat.GraphHighlightThe summary's emphasised lead — "Net profit up 48.3%."
Chat.GraphDetailThe supporting sentence after the highlight. Renders nothing when the agent wrote none.
Chat.GraphFootnoteThe provenance line under the chart, when there is one.
Chat.GraphExportDownloads the graph's numbers — CSV by default, or whatever onGraphExport does.
Chat.GraphSeriesIterator over the series in render order — the legend, and a table's columns.
Chat.GraphSeriesSwatchThe legend's colour chip. Carries data-series and data-series-index so it can be coloured from the same palette the chart uses.
Chat.GraphSeriesLabelThe series' legend label — "Q1 (Jan–Mar)".
Chat.GraphGroupsIterator 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.GraphGroupLabelThe group's label — "Revenue" (bar) or "Jan" (line).
Chat.GraphValuesIterator over the current group's numbers, in series order — a group missing a series simply has one fewer.
Chat.GraphValue
format?: (value: number) => ReactNode
One number, formatted for the graph's valueFormat. Carries data-series, data-series-index, and data-negative for a loss.
Chat.GraphValueLabelThe label of the series this number belongs to.

Reply actions

The copy control under a finished reply.

PartDescription
Chat.MessageActionsGate — renders once a reply has finished, so the controls never appear against half an answer. Renders nothing for the owner's own messages.
Chat.CopyMessageCopies 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.

PartDescription
Chat.TitleThe conversation's title. Renders nothing before the first message.
Chat.BackReturns to the resting view — a fresh conversation. Renders nothing while the resting view is already showing.
Chat.ExportDownloads 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.

PartDescription
Chat.HistoryRenders its children only while the panel is open.
Chat.HistoryToggleShows/hides the panel. Carries aria-expanded.
Chat.HistoryHeadingThe panel's heading.
Chat.HistoryEmptyRenders its children when there are no past conversations.
Chat.HistoryGroupsIterator — renders its children once per bucket.
Chat.HistoryGroupLabelThe bucket's heading — "Today", "Yesterday", "This week", "Last week", "This month", "Last month", then "March 2026" and older.
Chat.HistoryItemsIterator — renders its children once per conversation in the current bucket.
Chat.HistoryItemOne past conversation — clicking it loads that conversation into the transcript. Carries data-current for the conversation on screen.
Chat.HistoryItemTitleThe conversation's label.

Composer

The chat box. Enter sends; Shift+Enter inserts a newline.

PartDescription
Chat.FormA <form> whose submit sends the current input.
Chat.InputThe text field. Renders a <textarea> wired to the chat's input state and the Root's placeholder.
Chat.SubmitSends the composer. Disabled until there's something to send and no turn is running.
Chat.StopAborts 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.

PartDescription
Chat.SuggestionOne clickable prompt — sends its label as the next message.
Chat.SuggestionLabelThe prompt's text.

Shared

PartDescription
Chat.GreetingThe greeting shown above an empty transcript.
Chat.SubtitleThe sentence under the greeting.
Chat.DisclaimerStanding note that answers are generated and may be inaccurate.
Chat.RetryRe-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 series
const 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.