Turns
Framework-free turn grouping, part classification, and view-model helpers for building a chat UI on raw session messages.
@kortix/sdk/turns is the pure, framework-free engine behind the web and
mobile chat renderers: grouping raw session messages into turns,
classifying every message part into a typed shape, mapping tool parts to
per-tool view models, and aggregating cost/token totals. No React, no DOM
— every export is a plain function or type, safe to call from any host.
import { classifyPart, classifyTurn, toolInfo, toolViewModel } from '@kortix/sdk/turns';When to use this. The React hooks (useSessionSync,
useSession) already give you a live thread of messages. Reach for @kortix/sdk/turns
when you're rendering that thread — grouping it into turns, deciding what a
tool part means, or building a custom message/tool renderer instead of using
the reference one in apps/web.
A handful of the most useful exports — classifyPart, classifyTurn,
toolInfo, toolViewModel, and their types — are also re-exported from the
root @kortix/sdk entry, since they're the primary building blocks for a
custom chat renderer. Everything else (grouping, cost/token aggregation,
formatting, session-list helpers) is only available from the @kortix/sdk/turns
subpath. See what's on the root export below.
Part classification — classifyPart / classifyTurn
The primary entry point. @opencode-ai/sdk's wire Part union has 12
variants (text, reasoning, tool, file, subtask, patch, snapshot,
agent, retry, compaction, step-start, step-finish). classifyPart
normalizes any one of them into a ClassifiedPart — a discriminated union
keyed by kind, with the fields a renderer actually needs already resolved
(tool status, parsed JSON output, image/PDF detection, …).
import { classifyPart, type ClassifiedPart } from '@kortix/sdk/turns';
for (const part of message.parts) {
const classified: ClassifiedPart = classifyPart(part);
switch (classified.kind) {
case 'text':
render(classified.text);
break;
case 'tool':
render(classified.tool.title, classified.tool.status);
break;
// 'reasoning' | 'file' | 'subtask' | 'patch' | 'snapshot' | 'agent'
// | 'retry' | 'compaction' | 'step' | 'unknown'
}
}kind | shape |
|---|---|
text | { id, text, synthetic } — synthetic marks shell-mode's synthetic user prompt (skip rendering as a bubble) |
reasoning | { id, text } |
tool | { id, tool: ToolView } — see below |
file | { id, filename?, mime, url, isImage, isPdf } |
subtask | { id, description, agent, prompt, model? } |
patch | { id, hash, files, fileCount } |
snapshot | { id, snapshot } |
agent | { id, name } |
retry | { id, attempt, message, createdAt } |
compaction | { id, auto, overflow, tailStartId? } |
step | { id, phase: 'start' | 'finish', snapshot?, reason?, cost?, tokens? } |
unknown | { raw } — forward-compat fallback for a part type this SDK version doesn't know about |
The switch's default branch has a compile-time exhaustiveness check, so a
new opencode part variant fails this file's build instead of silently
rendering nothing; at runtime an unrecognized value degrades to 'unknown'
instead of throwing (e.g. an older client talking to a newer server).
Tool parts classify into a normalized ToolView, independent of the wire's
pending/running/completed/error status union:
interface ToolView {
name: string;
title: string;
status: 'pending' | 'running' | 'done' | 'error';
input?: Record<string, unknown>;
output?: string;
error?: string;
outputParsed?: unknown; // JSON.parse(output) when it parses (capped at 256KB)
outputText?: string; // the raw output text, always
}classifyToolState also detects embedded failures: some router/executor
tools (web_search, image_search, connector calls) return state.status: 'completed' even when their JSON body carries success: false or a
top-level error — a real failure that would otherwise render as a
successful call with raw JSON inside. ToolView.status is 'error' in that
case too.
import { classifyTurn, type ClassifiedTurn } from '@kortix/sdk/turns';
const turn: ClassifiedTurn = classifyTurn(assistantMessage);
// turn.parts — every part, classified
// turn.error — { name, message } normalized from message.info.error, if any
// turn.isEmpty — no error AND no part with visible content (the
// "failed turn renders as silence" bug, solved once here)Tool metadata — toolInfo vs getToolInfo
Two different lookups share the concept of "info about a tool" — don't
confuse them. toolInfo (from tool-registry.ts) is icon-free: a
{ label, category } pair, used internally by classifyPart and meant for
hosts that just need to know what kind of tool something is. getToolInfo
(from the module root, index.ts) is the richer, icon-aware lookup used
by the existing tool-card UI: { icon, title, subtitle }, with the subtitle
derived from the tool's input (a file path, a query, an agent description, …).
import { toolInfo, humanizeToolName, type ToolCategory } from '@kortix/sdk/turns';
toolInfo('bash'); // { label: 'Shell', category: 'shell' }
toolInfo('agent_spawn'); // prefix-matched: { label: 'Agent Spawn', category: 'task' }
humanizeToolName('session_spawn'); // 'Session Spawn'ToolCategory is 'shell' | 'files' | 'search' | 'edit' | 'web' | 'task' | 'other'. Unknown tool names never throw — the built-in registry covers
opencode's native tools plus Kortix's plugin tool families (agent_*,
session_*, task_*, trigger_*, project_*, pty_*, matched by prefix so
a new tool in an existing family is categorized correctly without a registry
update); anything else falls back to humanizeToolName(name) with category
'other'.
import { getToolInfo, type ToolInfo } from '@kortix/sdk/turns';
const info: ToolInfo = getToolInfo('write', { filePath: '/workspace/main.go' });
// { icon: 'file-pen', title: 'Write', subtitle: 'main.go /workspace' }getToolInfo(tool, input) covers every built-in and plugin tool by name —
read/list/glob/grep/webfetch/bash/edit/write/task/
todowrite/question, the session_*/agent_*/project_*/trigger_*/
pty_* families, and more — falling back to { icon: 'cpu', title: tool }
for anything it doesn't recognize by name.
Tool view models — toolViewModel
A layer on top of ToolView for product UIs that want to render specific
tool families specially instead of a generic JSON blob:
import { toolViewModel, type ToolViewModel } from '@kortix/sdk/turns';
const vm: ToolViewModel = toolViewModel(classifiedTool);
if (vm.kind === 'shell') {
render(vm.command, vm.stdout, vm.exitCode);
}kind | shape | tools |
|---|---|---|
web-search | { query, results?, answer?, error? } | web_search, websearch, image_search |
shell | { command, stdout?, exitCode? } | bash — strips <bash_metadata>/ANSI tags |
file-read | { path, preview? } | read |
file-write | { path, preview? } | write |
file-edit | { path, diff?: DiffLine[] } | edit, morph_edit — O(n) prefix/suffix line diff |
search | { pattern, matches?: SearchMatch[] } | grep, glob |
task | { description, agent? } | task |
todo | { items: TodoItem[] } | todowrite |
question | { questions: QuestionItem[], answers? } | question, ask |
generic | { label, inputPretty?, outputPretty? } | everything else (webfetch, image_gen, session_*, agent_*, project_*, trigger_*, pty_*, …) — always safe to render |
DiffLine is { type: 'added' | 'removed' | 'unchanged', text }. All string
fields are capped (4000 chars for pretty-printed JSON, 256KB before a diff is
even attempted) so a huge tool output never blows up a render.
Grouping messages into turns
A turn pairs one user message with the assistant messages that answered it — the unit a chat UI actually renders as one exchange.
import { groupMessagesIntoTurns, collectTurnParts, type TurnLike } from '@kortix/sdk/turns';
const turns: TurnLike[] = groupMessagesIntoTurns(messages);
for (const turn of turns) {
const parts = collectTurnParts(turn); // every part across turn.assistantMessages
}groupMessagesIntoTurns links assistant messages to their parent user
message via parentID, falling back to sequential ordering when parentID
is absent; it dedupes user messages by id (an optimistic copy racing the real
one) and attaches an orphan assistant message (no parentID, precedes every
user message) to the first turn instead of the last, so it renders at its
real chronological spot rather than under an unrelated later prompt.
Also: findLastTextPart(parts) (the turn's "response" — the last non-empty
text part), turnHasSteps(parts) (has a tool/compaction/snapshot/
patch part), isShellMode(turn) / getShellModePart(turn) (a turn that's
entirely a synthetic user prompt driving exactly one bash tool call — the
"shell mode" UI), and splitUserParts(parts) / isAttachment(part) (split a
user message's parts into image/PDF attachments vs sticky text/other parts).
Type guards
Narrow a PartLike by type, generic over your own part union:
import { isTextPart, isToolPart, getPartText } from '@kortix/sdk/turns';
if (isTextPart(part)) { /* part.type narrowed to 'text' */ }
const text = getPartText(part); // works for both 'text' and 'reasoning' partsAlso isReasoningPart, isFilePart, isAgentPart, isCompactionPart,
isSnapshotPart, isPatchPart.
Status & working state
import {
getWorkingState,
isLastUserMessage,
computeStatusFromPart,
getTurnStatus,
formatDuration,
shouldHideResponsePart,
} from '@kortix/sdk/turns';
const working = getWorkingState(sessionStatus, isLastUserMessage(msg.info.id, allMessages));
const status = getTurnStatus(parts, childMessages); // "Running commands..." etc.
formatDuration(4300); // "4s" — sub-second durations return '' (not worth a badge)getTurnStatus scans a turn's parts in reverse for the last meaningful
status line (computeStatusFromPart per-part, e.g. bash → "Running commands...", grep/glob → "Searching codebase..."); when the last part
is a running task/agent_task delegation tool, it drills into the
child session's own messages (pass childMessages) to surface the
sub-agent's real status instead of a generic "Delegating..." line.
shouldHideResponsePart(working, responsePartId) is true once a turn is
done and has a response part — the signal to pull that last text part out of
the steps list and show it in a dedicated "Response" section.
Errors
import { getTurnError, getChildSessionError, unwrapError } from '@kortix/sdk/turns';
getTurnError(turn); // the first assistant error in a turn, unwrapped
getChildSessionError(childMessages); // newest-first scan of a sub-agent's own messages
unwrapError(rawError); // deep JSON-unwrapping error normalizer both useunwrapError handles the router/executor error shapes this SDK actually
sees in practice: double-JSON-encoded strings, a JSON body embedded in an
otherwise plain-text prefix (Error: 402 Error: {"error":true,"message":"Insufficient credits"}),
and the common {message} / {error} / {data:{message}} object shapes —
falling back to the raw string/value if none of those match. It's also what
classifyTurn's TurnError and classifyRetry's retry message are built
from.
Cost & token aggregation
import { getTurnCost, getSessionCost, formatCost, formatTokens, COST_MARKUP } from '@kortix/sdk/turns';
const info = getTurnCost(partsWithMessage, modelPricingLookup); // undefined if no cost data
// info?.cost · info?.tokens.{input,output,reasoning,cacheRead,cacheWrite}
const sessionCost = getSessionCost(messages, modelPricingLookup);
formatCost(0.0032); // "$0.003"
formatTokens(12345); // "12k"Both aggregate from step-finish parts' reported cost/tokens, falling
back to estimating cost from token counts via a ModelPricingLookup when the
wire reports zero (or there are no step-finish parts at all — some
assistant messages only carry token totals on info.tokens). Every total is
multiplied by COST_MARKUP (1.2) so the displayed number matches what's
actually billed — this constant must stay in sync with KORTIX_MARKUP in
apps/api/src/config.ts.
Child session (sub-agent) helpers
A task/agent_spawn/session_spawn tool call delegates to a child
session — these helpers bridge a parent turn's rendering to that child's
own message stream.
import { getChildSessionId, getChildSessionToolParts, shouldShowToolPart } from '@kortix/sdk/turns';
const childId = getChildSessionId(taskToolPart); // from metadata, title, or output text
const steps = getChildSessionToolParts(childMessages); // child's tool parts, filteredgetChildSessionToolParts filters through shouldShowToolPart, which hides
purely-internal tools (todoread, context_info) from the step list.
Permission & question requests
Match a pending permission/question request to the tool call it's about, and compute which tool parts to hide from the step list while one is active:
import {
getPermissionForTool,
getQuestionForTool,
getHiddenToolParts,
isToolPartHidden,
getAnsweredQuestionParts,
} from '@kortix/sdk/turns';
const permission = getPermissionForTool(permissions, callID);
const hidden = getHiddenToolParts(activePermission, activeQuestion);
if (isToolPartHidden(part, messageId, hidden)) { /* skip in the step list */ }getAnsweredQuestionParts(turn, stepsExpanded, hasActiveQuestion) collects
already-answered question tool parts to render standalone outside the step
list (questions never render inside steps); it returns nothing while a
question is actively pending, so an old answered question doesn't compete
with the live one.
Path & label formatting
import { getFilename, getFileWithDir, getDirectory, relativizePath, stripAnsi } from '@kortix/sdk/turns';
getFilename('/workspace/src/main.go'); // "main.go"
getFileWithDir('/workspace/src/main.go'); // "main.go /src"
getDirectory('/workspace/src/main.go'); // "/workspace/src"
relativizePath('/workspace/src/main.go', '/workspace'); // "src/main.go"
stripAnsi(rawTerminalOutput); // strips ANSI escape codes, linear-timeAlso getAgentCardLabel(input) (a one-line label for a task/agent card, with
graceful fallbacks across description → title → message → prompt →
agent_id) and getDiagnostics(diagnosticsByFile, filePath) (LSP
diagnostics for a file, errors only, capped at 3).
Session list helpers
Sidebar/tab helpers, not turn-scoped, but shipped here since they operate on the same session data shape:
import { sortSessions, childMapByParent, allDescendantIds } from '@kortix/sdk/turns';
sessions.sort(sortSessions(Date.now())); // pins sessions updated in the last 60s to the top
const childMap = childMapByParent(sessions); // parentID -> child session ids
const descendants = allDescendantIds(childMap, sessionId); // full sub-agent tree, recursiveRetry helpers
import { getRetryInfo, getRetryMessage } from '@kortix/sdk/turns';
getRetryInfo(sessionStatus); // { attempt, message, next } if status.type === 'retry', message capped to 60 chars
getRetryMessage(sessionStatus); // the full, un-truncated retry error messageStructural types
The grouping/status functions above (groupMessagesIntoTurns,
getWorkingState, getTurnCost, …) are typed against minimal structural
protocols — PartLike, MessageInfoLike, MessageWithPartsLike, TurnLike,
ToolStateLike, ToolPartLike, SessionStatusLike, RequestWithToolLike,
TokenUsageLike — instead of the concrete @opencode-ai/sdk wire types.
That's deliberate: every host's own message/part union (web's rich type,
mobile's leaner local mirror) flows through unchanged as long as it has the
right shape, and TypeScript preserves your concrete type end to end instead
of widening it to the SDK's own. classifyPart / classifyTurn are the
exception — they're typed against the real @opencode-ai/sdk Part union on
purpose, so the exhaustiveness check is meaningful.
What's re-exported from @kortix/sdk
from root @kortix/sdk | from @kortix/sdk/turns only |
|---|---|
classifyPart, classifyTurn, all Classified*Part types, ClassifiedPart, ClassifiedTurn, TurnError, ToolView, ToolStatus | groupMessagesIntoTurns, collectTurnParts, findLastTextPart, turnHasSteps, isShellMode, getShellModePart, splitUserParts, isAttachment |
toolInfo, humanizeToolName, ToolCategory, ToolInfoEntry | getToolInfo, normalizeToolName, all type guards (isTextPart, …), getPartText |
toolViewModel, ToolViewModel, DiffLine, DiffLineType, WebSearchResultItem, SearchMatch, TodoItem, QuestionItem, QuestionOption | getWorkingState, isLastUserMessage, computeStatusFromPart, getTurnStatus, formatDuration, shouldHideResponsePart |
getTurnError, getChildSessionError, unwrapError | |
getTurnCost, getSessionCost, formatCost, formatTokens, COST_MARKUP | |
getChildSessionId, getChildSessionToolParts, shouldShowToolPart | |
getHiddenToolParts, isToolPartHidden, getAnsweredQuestionParts, getPermissionForTool, getQuestionForTool | |
getFilename, getFileWithDir, getDirectory, relativizePath, getAgentCardLabel, getDiagnostics, stripAnsi | |
sortSessions, childMapByParent, allDescendantIds | |
getRetryInfo, getRetryMessage | |
every structural type (PartLike, MessageWithPartsLike, TurnLike, ToolInfo, TurnCostInfo, RetryInfo, Diagnostic, ModelCostRates, ModelPricingLookup, …) |
If you only need the classification/tool-info/view-model primitives, importing
from root @kortix/sdk is fine. Reach for the @kortix/sdk/turns subpath
once you need grouping, cost, or any of the formatting/session-list helpers.
Next
- React hooks —
useSessionSync/useSessiongive you the live message list this module groups and classifies. - Modules — the other subpath imports.