kiseki
kiseki (軌跡, "trajectory") is a React component for reading agent traces. It renders an OpenTraces 0.9 TraceRecord: turns, reasoning, tool calls with their results, subagent branches, file edits, and outcomes, complete or still growing.
It renders agent semantics and nothing else. Your app keeps the job: custom renderers per tool, storage, auth, and dashboards.
Install
pnpm add @tetraslam/kiseki
# optional, only if your traces have patches:
pnpm add @pierre/diffs
React 19 is a peer dependency. @pierre/diffs is an optional peer: kiseki imports it the first time a patch is on screen, so apps that never show patches never load it.
Render a trace
import { Trace } from "@tetraslam/kiseki";
import "@tetraslam/kiseki/styles.css";
export function TraceView({ record }) {
return (
<div style={{ height: 640 }}>
<Trace record={record} />
</div>
);
}
<Trace> fills its parent's height and scrolls inside it. The first rows render on the server, so the first paint is the trace itself, and the virtualizer takes over before the browser paints.
To parse text yourself (a .json record, a JSON array, or JSONL with one record per line):
import { parseTraces, resolveGenerations } from "@tetraslam/kiseki";
const { records, issues } = parseTraces(text);
const latest = resolveGenerations(records); // the max generation_index of each session_id
Invalid steps are dropped and reported in issues, so a partial trace still renders.
Props
| prop | type | what it does |
|---|---|---|
record |
TraceRecord |
the trace. Pass a new record as it grows. |
live |
boolean |
unanswered calls are pending, and the view follows the tail |
renderers |
Renderers |
custom renderers, below |
loadPayload |
(stepIndex, callId) => Promise<string | undefined> |
full output for a call whose record holds only output_summary. Called when the call is expanded. |
loadTrace |
(ref) => Promise<TraceRecord> |
resolves a subagent_trajectory_ref when its branch unfolds |
loadPatch |
(patch) => Promise<PatchContent | undefined> |
diff content for a patch that carries none |
selectedStep, defaultSelectedStep, onSelectStep |
string | null |
the selected step's key |
expanded, defaultExpanded, onExpandedChange |
string[] |
open blocks: 12#reasoning, 12#<tool_call_id>, 12#patch:<patch_id> |
unfolded, defaultUnfolded, onUnfoldedChange |
string[] |
unfolded branch heads. Branches start folded. |
search, defaultSearch, onSearchChange |
string |
the search query |
permalink |
(key) => string |
the link c copies. Defaults to the current URL plus #step=<key>. |
header |
boolean | ReactNode |
hide the header, or replace it |
toolbar |
boolean |
hide the search toolbar |
keyboard |
"scoped" | "global" | false |
listen while the trace has focus (default), on the window, or not at all |
theme |
"light" | "dark" |
force a theme. By default kiseki follows a .dark class on an ancestor. |
ref |
Ref<TraceHandle> |
append(steps), scrollToStep(key), focus() |
A step's key is its step_index as a string. Steps of a subagent trace loaded through loadTrace are keyed <ref>/<step_index>.
Every state prop works controlled or uncontrolled. Controlled, it can live in the URL; nuqs fits well:
const [step, setStep] = useQueryState("step");
<Trace record={record} selectedStep={step} onSelectStep={setStep} />;
Custom renderers
Renderers receive the paired call, its observation, and the step. Default is kiseki's own renderer, so you can wrap it instead of replacing it.
import { Trace, type ToolRendererProps } from "@tetraslam/kiseki";
function PeopleCards(props: ToolRendererProps) {
const output = props.payload ?? props.observation?.content;
if (!output) return <props.Default {...props} />;
return <ProfileGrid people={JSON.parse(output)} />;
}
<Trace
record={record}
renderers={{
tools: { searchProfiles: PeopleCards },
fallback: undefined, // any tool without its own renderer
}}
/>;
You can also replace user, system, message (agent text, markdown through streamdown by default), reasoning, and patch.
Live mode
Pass a growing record with live, or append through the handle:
const trace = useRef<TraceHandle>(null);
<Trace ref={trace} record={record} live />;
// later, as steps finish:
trace.current?.append([step]);
New steps animate in without moving the rows already on screen. The view follows the tail and stops when the reader scrolls up; a "follow live" button brings it back. A call with no result yet shows a shimmer label.
Patches
OpenTraces patches carry snapshot ids, not diff text. kiseki finds content in this order:
- a
diff(unified) string on the patch. The kiseki adapters write this; it is an extension to OpenTraces. beforeandafterstrings on the patch- the producing tool call's input:
old_string/new_stringoroldString/newString(Edit),edits[](MultiEdit),content(Write), or a unifiedpatch/diff(apply_patch) loadPatch(patch)
Diffs render with @pierre/diffs.
Compare two runs
import { TraceCompare } from "@tetraslam/kiseki";
<TraceCompare a={runA} b={runB} />;
Steps are aligned by signature (role plus sorted tool names) through a longest common subsequence (Myers' diff). Steps only one run took sit across from a gap, and the first divergence is labeled.
Keyboard
| key | action |
|---|---|
j / k |
next / previous step |
enter |
open or close every block of the step |
[ / ] |
fold / unfold a subagent branch |
/ |
search. enter and shift+enter move between matches. |
c |
copy a permalink to the step |
esc |
clear the search, then the selection |
Search covers content, reasoning, tool names, inputs, summaries, and whatever output the record holds. It does not search payloads that haven't loaded.
Adapters
@tetraslam/kiseki-adapters turns other formats into TraceRecords. Every adapter takes text or parsed objects, never a path, so it runs in the browser too.
import { detect } from "@tetraslam/kiseki-adapters";
const result = detect(await file.text()); // { format, records, issues } or null
| import | export | input |
|---|---|---|
/ai-sdk |
createTraceRecorder(), fromSteps() |
AI SDK StepResults |
/claude-code |
fromClaudeCode(text, { subagents }) |
~/.claude/projects/<project>/<session>.jsonl |
/opencode |
fromOpenCode(session, { children }) |
opencode session export <id> |
/codex |
fromCodex(text) |
~/.codex/sessions/…/rollout-*.jsonl |
/pi |
fromPi(text) |
~/.pi/agent/sessions/…/*.jsonl |
Record an AI SDK agent as it runs:
import { createTraceRecorder } from "@tetraslam/kiseki-adapters/ai-sdk";
const recorder = createTraceRecorder({ agent: { name: "my-agent" }, prompt });
const result = await generateText({ model, tools, prompt, onStepFinish: recorder.onStepFinish });
save(recorder.record());
Theming
Every color, font, size, and curve is a --kiseki-* CSS variable, set at zero specificity, so any rule on .kiseki overrides it:
.kiseki {
--kiseki-font: "Inter Variable", sans-serif;
--kiseki-mono: "Commit Mono", monospace;
--kiseki-bg: #e9e8e3;
--kiseki-accent: #d5683f;
}
Dark mode follows a .dark class or data-theme="dark" on an ancestor, or the theme prop. kiseki doesn't load fonts; it names Inter and Commit Mono and falls back to system faces.
Performance
Measured by scripts/perf.mjs in headless Chromium against the built site, on a generated 10,000-step trace (28 MB of JSONL):
- first rows paint a median of 66 ms after the record is parsed (budget: 100 ms)
- cumulative layout shift is 0 while scrolling, expanding, and streaming, and rows above an expanded call move 0 px
- @pierre/diffs loads only when a patch is opened or on screen
- files over 5 MB dropped on kiseki.sh parse in a web worker
What isn't proven
- Claude Code
MultiEdit,NotebookEdit, and the older inline sidechain format haven't been seen in a real session. - The Pi adapter was checked against one real session with no tool calls; tools, patches, and branching follow Pi's docs.
- The Codex adapter was checked on eight rollouts, none with subagents.
- The AI SDK adapter was checked with real
StepResults from a mock model, not a live provider. - Performance numbers come from one machine in headless Chromium. Other browsers aren't measured.