Activities
Show plans, progress, and background work that the user should see and the model should not.
An activity is AG-UI's channel for display-only, structured progress: a plan, a progress bar, a workflow step, a background job. Activities are not part of the conversation. The model never sees them, and Hashbrown never sends them back to the agent.
Hashbrown is headless. It turns the activity events into typed messages in chat.messages, and your app renders them.
Activities, Tool Calls, and Server Tool Calls
| Kind | Who asked for it | Does the model continue from it? | Where it appears |
|---|---|---|---|
| Tool call | The model | Yes, from the tool's result | toolCalls on an assistant |
| Server tool call | The model, run by the server | Yes | serverToolCalls on an assistant |
| Activity | Your server or a third party | No | Its own role: 'activity' entry |
Use a tool call when the model needs the result. Use an activity when the user needs to see something. The two can describe the same piece of work. Hashbrown keeps them separate and records the link, which links explains.
The Activity Message
Each activity is its own entry in chat.messages, positioned where it arrived.
type ActivityMessage = {
role: 'activity';
id: string;
activityType: string;
content: Readonly<Record<string, unknown>>;
metadata?: Metadata;
links: ActivityLinks;
};
idis the AG-UI activity message id. Later events for the same id update the same message. Ids must be unique within the thread: reusing one, even in a later run, updates the earlier activity in place instead of adding a new one.activityTypeis the producer's kind, for exampleplanorprogress. Switch on it to choose a component.contentis untyped. Narrow it byactivityTypein your app. Hashbrown deeply freezes it and replaces it with a new object on every update, so memoizing overchat.messagessees each change.
An activity arrives in one of two ways. An ACTIVITY_SNAPSHOT event creates the activity, or replaces its content when the id exists. A replace: false snapshot keeps the existing content and only merges metadata. An ACTIVITY_DELTA event applies an RFC 6902 JSON Patch to the content.
Activities from earlier runs stay in history. A MESSAGES_SNAPSHOT that carries activities replaces the activity set. A snapshot that carries none keeps the activities you already have.
Links
Each activity records what it belonged to when it first arrived.
| Field | Meaning |
|---|---|
runId | The run that delivered it. Set for the current run only. |
assistantMessageId | The last assistant message started in that run, if any. |
toolCallId | The most recent tool call of any kind in that run, if any. |
subagentRunId | The subagent run that sent it, if the agent reports one. |
An activity sent after the model's answer links to that answer, including an answer that only calls tools. One sent before any assistant message has started has no assistantMessageId. Later snapshots and deltas keep the links, and incoming links are never trusted: activities restored with setMessages or a MESSAGES_SNAPSHOT get their links recomputed from their position. subagentRunId is the exception. It is kept on restore, not recomputed, because position cannot tell which subagent sent an activity.
reload keeps the activities the reloaded turn sent before its answer and drops the ones that came after it. The kept activities are relinked from their position, like any restored activity.
User and assistant messages expose a stable id, so a link always has something to point at. The streaming assistant message keeps the id it has once it is committed.
Render a Flat Timeline
When activities should appear in the order they arrived, switch on role and render each one in place.
import { useChat } from '@hashbrownai/react';
function planSteps(content: Readonly<Record<string, unknown>>): string[] {
const steps = content['steps'];
return Array.isArray(steps) ? steps.map(String) : [];
}
export function Timeline() {
const chat = useChat({ system: 'You are a helpful assistant.' });
return (
<>
{chat.messages.map((message, index) => {
switch (message.role) {
case 'user':
return (
<p key={index} className="user">
{message.content}
</p>
);
case 'assistant':
return (
<p key={index} className="assistant">
{message.content}
</p>
);
case 'activity':
return message.activityType === 'plan' ? (
<ul key={message.id} className="plan">
{planSteps(message.content).map((step) => (
<li key={step}>{step}</li>
))}
</ul>
) : null;
default:
return null;
}
})}
</>
);
}
Always handle 'activity' in your switch on role, even if you ignore it. An activity message is a normal member of the list.
An activity that arrives mid-run shows up immediately, next to the streaming assistant message. It does not wait for the run to finish.
Render Nested Cards
When activities belong with the assistant answer or tool call they describe, use groupTurns. It groups the flat list into turns and buckets each turn's activities by their links.
import { useMemo } from 'react';
import { groupTurns } from '@hashbrownai/core';
import { useChat } from '@hashbrownai/react';
export function Turns() {
const chat = useChat({ system: 'You are a helpful assistant.' });
const turns = useMemo(() => groupTurns(chat.messages), [chat.messages]);
return (
<>
{turns.map((turn, index) => (
<section key={turn.user?.id ?? index}>
{turn.user && <p className="user">{turn.user.content}</p>}
{turn.assistants.map((assistant) => (
<article key={assistant.id}>
<p>{assistant.content}</p>
{(turn.activitiesByAssistant.get(assistant.id) ?? []).map(
(activity) => (
<aside key={activity.id}>{activity.activityType}</aside>
),
)}
</article>
))}
{turn.unlinkedActivities.map((activity) => (
<aside key={activity.id}>{activity.activityType}</aside>
))}
</section>
))}
</>
);
}
Each Turn holds:
user: the user message that opened the turn, if any.assistants: the assistant messages of the turn, in order.activitiesByAssistant: activities linked to an assistant message, keyed by itsid.activitiesByToolCall: activities linked to a client or server tool call, keyed bytoolCallId. Use this to render a card next to the tool call it belongs to.unlinkedActivities: activities in the turn with neither link.errors: the error messages of the turn.
groupTurns is a pure function. It does not change its input and keeps the input order inside every bucket. Messages before the first user message form a turn without a user.
Receiving Activities from Third-Party Backends
Any AG-UI backend can send ACTIVITY_SNAPSHOT and ACTIVITY_DELTA events, and Hashbrown handles them the same way. Your app needs no extra setup.
Hashbrown is forgiving about malformed input from a backend you do not control:
- A delta for an unknown id is dropped.
- A delta whose patch fails to apply is dropped. Valid metadata on it is kept.
- A dropped delta logs a warning to the console and never ends the run.
Activities are display material, so Hashbrown removes them from the messages it sends back to the agent on later runs.
chat.reload() and the logic that decides whether the last message needs an answer skip trailing activities. An activity never counts as the last user or assistant message.
Sending Activities from Your Server
Hashbrown's own adapters can send activities too. See Sending activities for the activities option, available on every adapter.