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.

shared/turn-summary.ts view on GitHub
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.

server/summarize.ts view on GitHub
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:

index.server.ts view on GitHub
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.

client/summary-card.tsx view on GitHub
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>
    );
  };
}
index.client.tsx view on GitHub
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)
A fake agent turn in the Paseo web UI: the Write notes.md tool call, the reply 'done', and below it the plugin's Turn summary card with '1 file changed, 0 commands run', notes.md and an All turns button.
The card under the fake agent's turn, from ppd web. The model picker shows the fake agent's only model, Scripted.

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.

Real agents

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.