Timeline renderer
When an agent turn ends, the server summarizes the files it wrote and the commands it ran, and appends a card to the
agent's timeline. The client renders the card and a screen with every turn. This is the agent-hook template.
npx --package paseo-plugin-devkit ppd create ~/code/turn-cards --template agent-hook
cd ~/code/turn-cards && npm install
npm run check && npm test ...
PASS client-eval contributions: addTimelineRenderer, addScreen
...
13 passed, 0 warnings, 0 failed, 0 skipped
Test Files 2 passed (2)
Tests 15 passed | 1 skipped (16) The row
A plugin timeline row has a kind, a version and data. The client validates data with the same schema before
it renders, so the schema lives in shared/. The RPC lets the screen list every summary of an agent.
import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
/** Timeline row kind. Bump `SUMMARY_VERSION` when the data shape changes. */
export const SUMMARY_KIND = "turn-summary";
export const SUMMARY_VERSION = 1;
export const summarySchema = z.object({
agentId: z.string(),
turnId: z.string().nullable(),
outcome: z.enum(["completed", "failed", "canceled"]),
files: z.array(z.string()),
commands: z.array(z.string()),
toolCalls: z.number().int(),
endedAt: z.string(),
});
export type TurnSummary = z.output<typeof summarySchema>;
export const listSummariesRpc = defineRpc({
name: "summaries.list",
input: z.object({ agentId: z.string().min(1) }),
output: z.object({ summaries: z.array(summarySchema) }),
});The hook
agent.turn_ended carries the whole conversation, so the summary starts after the last user message. Files come
from the detail.filePath of write and edit tool calls; a file a shell command writes is not attributed by the host.
import type { PluginLifecycleEvents } from "@getpaseo/plugin/server";
import type { TurnSummary } from "../shared/turn-summary";
export type TurnEndedEvent = PluginLifecycleEvents["agent.turn_ended"];
type TimelineItem = TurnEndedEvent["timeline"][number];
/** `turn_ended` carries the whole conversation: the turn that just ended starts after the last user message. */
export function currentTurn(timeline: readonly TimelineItem[]): readonly TimelineItem[] {
let start = 0;
timeline.forEach((item, index) => {
if (item.type === "user_message") start = index + 1;
});
return timeline.slice(start);
}
/**
* Files come from write and edit tool calls (`detail.filePath`). Files a shell command writes are not
* attributed by the host; only the command is known.
*/
export function summarizeTurn(event: TurnEndedEvent, endedAt: Date = new Date()): TurnSummary {
const files = new Set<string>();
const commands: string[] = [];
let toolCalls = 0;
for (const item of currentTurn(event.timeline)) {
if (item.type !== "tool_call") continue;
toolCalls += 1;
const detail = item.detail;
if (detail.type === "write" || detail.type === "edit") files.add(detail.filePath);
else if (detail.type === "shell") commands.push(detail.command);
}
return {
agentId: event.agent.id,
turnId: event.turnId,
outcome: event.outcome.kind,
files: [...files],
commands,
toolCalls,
endedAt: endedAt.toISOString(),
};
}Turns without tool calls get no card. The row is appended with paseo.agents.ref(id).timeline.append, and kept in
memory for the RPC:
import { randomUUID } from "node:crypto";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { summarizeTurn } from "./server/summarize";
import { listSummariesRpc, SUMMARY_KIND, SUMMARY_VERSION, type TurnSummary } from "./shared/turn-summary";
const KEEP_PER_AGENT = 50;
export default function contribute(server: PluginServerContext) {
const byAgent = new Map<string, TurnSummary[]>();
const stopListening = server.on("agent.turn_ended", async (event, { paseo }) => {
const summary = summarizeTurn(event);
if (summary.toolCalls === 0) return; // plain chat turns get no card
const kept = [...(byAgent.get(summary.agentId) ?? []), summary].slice(-KEEP_PER_AGENT);
byAgent.set(summary.agentId, kept);
await paseo.agents.ref(summary.agentId).timeline.append({
type: "plugin",
id: `turn-summary-${randomUUID()}`,
kind: SUMMARY_KIND,
version: SUMMARY_VERSION,
data: summary,
});
console.log(`turn summary appended for ${summary.agentId}: ${summary.files.length} file(s), ${summary.commands.length} command(s)`);
});
server.handle(listSummariesRpc, ({ agentId }) => ({ summaries: byAgent.get(agentId) ?? [] }));
return () => {
stopListening();
};
}The card and the screen
Timeline renderers get no navigation props, so the card closes over the client from contribute to open the
screen.
import { Pressable, Text, View } from "react-native";
import type { PluginClientContext, PluginTimelineItemProps } from "@getpaseo/plugin/client";
import type { TurnSummary } from "../shared/turn-summary";
export const SCREEN_ID = "summaries";
const SHOWN_FILES = 5;
function basename(path: string): string {
return path.split(/[\\/]/).pop() || path;
}
/** Timeline renderers get no navigation props: close over the `client` from `contribute` to open a screen. */
export function makeSummaryCard(client: PluginClientContext) {
return function SummaryCard({ item, theme, agentId }: PluginTimelineItemProps<TurnSummary>) {
const c = theme.colors;
const { files, commands, outcome } = item.data;
const more = files.length - SHOWN_FILES;
return (
<View style={{ borderWidth: 1, borderColor: c.border, borderRadius: 8, padding: 12, gap: 6, backgroundColor: c.surface1 }}>
<Text style={{ color: c.foreground, fontWeight: "600" }}>
Turn summary{outcome === "completed" ? "" : ` (${outcome})`}
</Text>
<Text style={{ color: c.foregroundMuted }}>
{files.length} file{files.length === 1 ? "" : "s"} changed, {commands.length} command{commands.length === 1 ? "" : "s"} run
</Text>
{files.slice(0, SHOWN_FILES).map((file) => (
<Text key={file} numberOfLines={1} style={{ color: c.foreground, fontFamily: "monospace", fontSize: 12 }}>
{basename(file)}
</Text>
))}
{more > 0 ? <Text style={{ color: c.foregroundMuted, fontSize: 12 }}>and {more} more</Text> : null}
<Pressable
accessibilityRole="button"
accessibilityLabel="All turns"
onPress={() => client.openScreen({ screenId: SCREEN_ID, params: { agentId } })}
style={{ alignSelf: "flex-start", borderRadius: 6, paddingHorizontal: 12, paddingVertical: 6, backgroundColor: c.accent }}
>
<Text style={{ color: c.accentForeground, fontSize: 13 }}>All turns</Text>
</Pressable>
</View>
);
};
}import type { PluginClientContext } from "@getpaseo/plugin/client";
import { SummariesScreen } from "./client/summaries-screen";
import { makeSummaryCard, SCREEN_ID } from "./client/summary-card";
import { SUMMARY_KIND, SUMMARY_VERSION, summarySchema } from "./shared/turn-summary";
export default function contribute(client: PluginClientContext) {
const cleanups = [
client.addTimelineRenderer({ kind: SUMMARY_KIND, version: SUMMARY_VERSION, schema: summarySchema, Component: makeSummaryCard(client) }),
client.addScreen({ id: SCREEN_ID, title: "Turn summaries", Component: SummariesScreen }),
];
return () => {
for (const cleanup of cleanups) cleanup();
};
}Run it with the fake agent
mkdir -p /tmp/turn-cards-ws
npx ppd daemon up --fake-agent
npx ppd install
npx ppd paseo -- run --provider fake-agent --cwd /tmp/turn-cards-ws '{"steps":[{"write":{"path":"notes.md","content":"hi"}},{"say":"done"}]}'
npx ppd paseo -- plugin logs turn-cards
npx ppd web --shot out/card.png The plugin log shows the hook ran:
turn summary appended for 544f3a69-ae2b-465c-af06-6d3183b8fbf9: 1 file(s), 0 command(s)
Test it
paseo logs does not show plugin rows; agentTimeline reads the canonical timeline, plugin rows included. Hooks
run asynchronously, so poll:
import { agentTimeline, pluginRows, runFakeAgentTurn, sendFakeAgentTurn } from "paseo-plugin-devkit";
const turn = runFakeAgentTurn(
[
{ write: { path: "notes.md", content: "first\n" } },
{ bash: { command: "npm test", output: "ok", exitCode: 0 } },
{ say: "Done." },
],
{ cwd: workspace },
);
// waitFor polls until the probe returns something other than null (see the test linked below).
const [card] = await waitFor(() => {
const rows = pluginRows(agentTimeline(turn.agentId), "turn-summary");
return rows.length ? rows : null;
});
expect(card).toMatchObject({ data: { outcome: "completed", files: [join(workspace, "notes.md")], commands: ["npm test"] } });
// A chat-only turn gets no card.
sendFakeAgentTurn(turn.agentId, [{ say: "just chatting" }]); The devkit's test/create-e2e.test.ts runs
this against a dev daemon it starts and stops itself, with waitFor and a failing turn.
The fake agent emits the canonical write, edit and shell items that Paseo maps real tools onto. Real adapters
can differ in the details, so run a hook once with a real agent before you ship it.