| name | feedback-actionability-design |
| description | Turning Bouts evaluation breakdowns into coaching items that are concrete enough to act on — specificity ladder, failure code → next step derivation, deduplication across submissions, and a priority-ordered TSX coaching display component. |
Feedback Actionability Design
Review Checklist
Feedback vs Coaching: The Core Distinction
This distinction is not pedantic — it determines whether users leave with something actionable.
Feedback describes what happened:
"Your agent failed to verify its output before responding."
Coaching prescribes what to change:
"Add an explicit verification call immediately after your main processing step. The 3 top-scoring submissions in this lane all verified before responding."
Feedback is backward-looking. Coaching is forward-looking. Evaluation platforms default to feedback because it's easier to generate — just describe the score. Coaching requires knowing what the user should do differently, which requires connecting the failure code to a specific remediation.
The rule: Every coaching item must answer "what specifically should I do next time?" If it doesn't answer that question, it's feedback, not coaching. Send it back.
The Specificity Ladder
Every coaching item can be scored on a specificity scale. Items below 60 should not be shown to users.
export interface CoachingItem {
failureCode: string;
text: string;
rationale: string;
specificity: number;
priority: 'primary' | 'supporting' | 'stretch';
laneId: string;
impactPoints: number;
}
export interface SpecificityCheck {
namedSpecificBehavior: boolean;
includesExample: boolean;
quantifiedImpact: boolean;
hasLaneContext: boolean;
avoidsPlatitudes: boolean;
hasTimeBinding: boolean;
}
export function scoreSpecificity(checks: SpecificityCheck): number {
let score = 0;
if (checks.namedSpecificBehavior) score += 25;
if (checks.includesExample) score += 20;
if (checks.quantifiedImpact) score += 15;
if (checks.hasLaneContext) score += 10;
if (checks.avoidsPlatitudes) score += 20;
if (checks.hasTimeBinding) score += 10;
return score;
}
Failure Code Registry and Coaching Item Generator
Failure codes are the bridge between the judge's assessment and the coaching item. Each code maps to a specific coaching template.
import type { CoachingItem } from './specificity';
export type FailureCode =
| 'FW-001'
| 'FW-002'
| 'FW-003'
| 'FW-004'
| 'FW-005'
| 'CL-001'
| 'CL-002'
| 'CL-003'
| 'EF-001'
| 'EF-002'
| 'EF-003'
| string;
export interface FailureCodeDefinition {
code: FailureCode;
laneAffected: string;
impactPoints: number;
primaryCoaching: string;
primaryRationale: string;
escalatedCoaching: string;
escalatedRationale: string;
specificity: number;
}
export const FAILURE_CODE_REGISTRY: Record<string, FailureCodeDefinition> = {
'FW-001': {
code: 'FW-001',
laneAffected: 'Verification',
impactPoints: 15,
primaryCoaching: 'Add an explicit verification call immediately after your main processing step, before generating your response.',
primaryRationale: 'The top 3 submissions in the Verification lane all verified before responding. This single step accounts for ~15 points.',
escalatedCoaching: 'Your verification step is missing for the second time. Specifically: after calling process_task(), add a call to verify_output(result) and include the verification outcome in your response.',
escalatedRationale: 'This has been flagged in your previous submission. The pattern is consistent — add it as a mandatory step in your agent\'s workflow.',
specificity: 90,
},
'FW-002': {
code: 'FW-002',
laneAffected: 'Relevance',
impactPoints: 10,
primaryCoaching: 'Before each tool call, state explicitly which task requirement it addresses.',
primaryRationale: 'Judges in the Relevance lane look for grounded tool use — every action should connect back to a constraint from the task brief.',
escalatedCoaching: 'Add a one-line comment in your reasoning before each tool call: "This addresses constraint X from the task." Judges cannot score relevance without visible grounding.',
escalatedRationale: 'Your tool calls are being scored as ungrounded for the second time. Make the connection to task requirements explicit and visible.',
specificity: 85,
},
'FW-003': {
code: 'FW-003',
laneAffected: 'Completeness',
impactPoints: 18,
primaryCoaching: 'Before generating your final response, enumerate all constraints from the task brief and confirm each is satisfied.',
primaryRationale: 'The Completeness lane checks every constraint, not just the primary one. Enumerate them and confirm explicitly — this adds ~18 points when done correctly.',
escalatedCoaching: 'Your response is being cut before all constraints are met. Add a final checklist step: list each constraint from the task brief, mark it satisfied or not, and only respond after all are marked satisfied.',
escalatedRationale: 'This is the second time your response was generated before all constraints were satisfied. A structured constraint check at the end is the fix.',
specificity: 92,
},
'FW-004': {
code: 'FW-004',
laneAffected: 'Planning',
impactPoints: 12,
primaryCoaching: 'Spend at least 2–3 explicit reasoning steps on planning before your first tool call or action.',
primaryRationale: 'The Planning lane scores whether the agent demonstrates a coherent strategy. Two or more visible planning steps before acting is the threshold for full credit.',
escalatedCoaching: 'Your planning phase is consistently too brief. Before ANY tool call, explicitly state: (1) what the task requires, (2) what your approach will be, (3) what the expected outcome is.',
escalatedRationale: 'Planning scores have been low across both your submissions. This structured approach is the pattern used by top-scoring submissions.',
specificity: 80,
},
'CL-001': {
code: 'CL-001',
laneAffected: 'Clarity',
impactPoints: 8,
primaryCoaching: 'State what action you took and what the result was at the end of each significant step.',
primaryRationale: 'The Clarity lane checks whether a reader can follow what the agent did. Explicit "I did X, which resulted in Y" statements at each step are the pattern.',
escalatedCoaching: 'Your responses are consistently unclear about what happened. Use this template at each step: "I [specific action]. This [specific result]. Next I will [specific next action]."',
escalatedRationale: 'Clarity scores have been below median twice. The template above is the fastest fix.',
specificity: 82,
},
'EF-001': {
code: 'EF-001',
laneAffected: 'Efficiency',
impactPoints: 7,
primaryCoaching: 'Check if a tool call result already contains the information you need before calling another tool.',
primaryRationale: 'Redundant tool calls directly cost points in the Efficiency lane. Before each call, ask: do I already have this result?',
escalatedCoaching: 'Your submissions consistently include redundant tool calls. Add a "do I already know this?" check before every tool call. If the result exists in context, use it — don\'t re-fetch.',
escalatedRationale: 'This pattern has appeared in both submissions. It\'s a systematic habit, not a one-time mistake.',
specificity: 78,
},
};
export function generateCoachingItems(
failureCodes: string[],
historicalFailureCodes: string[][],
maxItems = 3
): CoachingItem[] {
if (failureCodes.length === 0) return [];
const items: CoachingItem[] = [];
for (const code of failureCodes) {
const def = FAILURE_CODE_REGISTRY[code];
if (!def) continue;
const recentCount = historicalFailureCodes
.slice(0, 2)
.filter(codes => codes.includes(code)).length;
const useEscalated = recentCount >= 2;
const text = useEscalated ? def.escalatedCoaching : def.primaryCoaching;
const rationale = useEscalated ? def.escalatedRationale : def.primaryRationale;
items.push({
failureCode: code,
text,
rationale,
specificity: def.specificity,
priority: 'supporting',
laneId: def.laneAffected,
impactPoints: def.impactPoints,
});
}
items.sort((a, b) => b.impactPoints - a.impactPoints);
return items.slice(0, maxItems).map((item, index) => ({
...item,
priority: index === 0 ? 'primary' : index === 1 ? 'supporting' : 'stretch',
}));
}
export function filterBySpecificity(
items: CoachingItem[],
threshold = 60
): CoachingItem[] {
return items.filter(item => item.specificity >= threshold);
}
Deduplication Logic Across Historical Submissions
When the same agent submits multiple times without improving on a specific failure, the coaching should escalate — more specific, more direct.
import { generateCoachingItems, filterBySpecificity } from './failure-codes';
import type { CoachingItem } from './specificity';
export interface SubmissionHistory {
submissionId: string;
failureCodes: string[];
submittedAt: Date;
}
export function computeCoachingWithHistory(
currentFailureCodes: string[],
submissionHistory: SubmissionHistory[],
maxItems = 3
): {
items: CoachingItem[];
escalatedCodes: string[];
resolvedCodes: string[];
} {
const sortedHistory = [...submissionHistory].sort(
(a, b) => b.submittedAt.getTime() - a.submittedAt.getTime()
);
const historicalFailureCodes = sortedHistory.map(s => s.failureCodes);
const previousCodes = new Set(sortedHistory.flatMap(s => s.failureCodes));
const currentCodeSet = new Set(currentFailureCodes);
const resolvedCodes = [...previousCodes].filter(code => !currentCodeSet.has(code));
const escalatedCodes = currentFailureCodes.filter(code => {
const recentCount = historicalFailureCodes.slice(0, 2).filter(codes => codes.includes(code)).length;
return recentCount >= 2;
});
const rawItems = generateCoachingItems(currentFailureCodes, historicalFailureCodes, maxItems * 2);
const filteredItems = filterBySpecificity(rawItems);
const finalItems = filteredItems.slice(0, maxItems);
return {
items: finalItems,
escalatedCodes,
resolvedCodes,
};
}
TSX Coaching Display Component
'use client';
import React, { useState } from 'react';
import { cn } from '@/lib/utils';
import type { CoachingItem } from '@/lib/coaching/specificity';
interface CoachingBreakdownProps {
items: CoachingItem[];
resolvedCodes?: string[];
agentName: string;
}
const priorityConfig = {
primary: {
label: 'Key Focus',
labelStyle: 'bg-indigo-100 text-indigo-700 border border-indigo-200',
cardStyle: 'border-indigo-200 bg-white',
iconBg: 'bg-indigo-50',
icon: '🎯',
},
supporting: {
label: 'Also Address',
labelStyle: 'bg-gray-100 text-gray-600 border border-gray-200',
cardStyle: 'border-gray-200 bg-white',
iconBg: 'bg-gray-50',
icon: '↑',
},
stretch: {
label: 'Bonus',
labelStyle: 'bg-gray-50 text-gray-400 border border-gray-100',
cardStyle: 'border-gray-100 bg-gray-50',
iconBg: 'bg-gray-50',
icon: '◦',
},
};
export function CoachingBreakdown({
items,
resolvedCodes = [],
agentName,
}: CoachingBreakdownProps) {
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const toggleExpand = (code: string) => {
setExpandedItems(prev => {
const next = new Set(prev);
next.has(code) ? next.delete(code) : next.add(code);
return next;
});
};
if (items.length === 0) {
return (
<div className="rounded-xl bg-emerald-50 border border-emerald-200 p-5">
<p className="text-sm font-semibold text-emerald-900">No specific improvements flagged.</p>
<p className="text-xs text-emerald-700 mt-1">
The judges found no significant pattern failures in {agentName}'s submission.
</p>
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900">
Next submission coaching
</h3>
<span className="text-xs text-gray-400">
{items.length} item{items.length !== 1 ? 's' : ''} · highest impact first
</span>
</div>
{/* Resolved codes — celebrate wins */}
{resolvedCodes.length > 0 && (
<div className="rounded-lg bg-emerald-50 border border-emerald-100 px-4 py-3">
<p className="text-xs text-emerald-700 font-medium">
✓ {resolvedCodes.length} issue{resolvedCodes.length !== 1 ? 's' : ''} from your last submission {resolvedCodes.length === 1 ? 'was' : 'were'} resolved.
</p>
</div>
)}
{/* Coaching items — ordered by priority (primary first) */}
{items.map((item) => {
const cfg = priorityConfig[item.priority];
const isExpanded = expandedItems.has(item.failureCode);
return (
<div
key={item.failureCode}
className={cn('rounded-xl border overflow-hidden', cfg.cardStyle)}
>
<div className="px-5 py-4">
<div className="flex items-start gap-3">
<div className={cn('mt-0.5 h-8 w-8 shrink-0 rounded-lg flex items-center justify-center text-base', cfg.iconBg)}>
{cfg.icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1.5">
<span className={cn('text-[10px] font-semibold px-2 py-0.5 rounded-full', cfg.labelStyle)}>
{cfg.label}
</span>
<span className="text-xs text-gray-400">{item.laneId}</span>
<span className="text-xs text-indigo-500 font-medium">
~{item.impactPoints} pts
</span>
</div>
<p className="text-sm text-gray-800 leading-relaxed">{item.text}</p>
{/* Expandable rationale */}
<button
type="button"
onClick={() => toggleExpand(item.failureCode)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(item.failureCode);
}
}}
className="mt-2 flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600 transition-colors"
aria-expanded={isExpanded}
aria-controls={`rationale-${item.failureCode}`}
>
<svg
className={cn('h-3 w-3 transition-transform', isExpanded && 'rotate-90')}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{isExpanded ? 'Less' : 'Why this matters'}
</button>
</div>
</div>
</div>
{/* Rationale panel */}
<div
id={`rationale-${item.failureCode}`}
className={cn(
'px-5 border-t border-gray-100 bg-gray-50 transition-all duration-150 overflow-hidden',
isExpanded ? 'py-3 max-h-40' : 'max-h-0 py-0'
)}
>
<p className="text-xs text-gray-600 leading-relaxed">{item.rationale}</p>
</div>
</div>
);
})}
{/* Hard limit note */}
<p className="text-xs text-gray-400 text-center">
Showing top {items.length} of potentially more issues · focus here first
</p>
</div>
);
}
Anti-Patterns
❌ Showing all failure codes as coaching items
const coachingItems = failureCodes.map(code => ({
code,
text: FAILURE_CODE_REGISTRY[code]?.primaryCoaching ?? code,
}));
const rawItems = generateCoachingItems(failureCodes, history, 6);
const gatedItems = filterBySpecificity(rawItems, 60);
const finalItems = gatedItems.slice(0, 3);
❌ Same coaching item every submission without escalation
function getCoaching(code: string): string {
return FAILURE_CODE_REGISTRY[code]?.primaryCoaching ?? '';
}
const recentCount = history.slice(0, 2).filter(s => s.failureCodes.includes(code)).length;
const text = recentCount >= 2
? FAILURE_CODE_REGISTRY[code]?.escalatedCoaching
: FAILURE_CODE_REGISTRY[code]?.primaryCoaching;
❌ Feedback framing instead of coaching framing
{ text: 'Your agent did not verify its output before responding.' }
{ text: 'Add an explicit verify_output() call immediately after process_task(). Do this before any response generation.' }
Common Failures to Catch in Review
| Failure | Symptom | Fix |
|---|
| All failure codes displayed without limit | User sees 7 coaching items, ignores all of them | Hard cap at 3; sort by impactPoints desc first |
| Unknown failure codes produce empty coaching | Users get no coaching for new judges | Add a fallback generic coaching item for unregistered codes |
| Same primary coaching after 3+ identical failures | User sees same advice they've ignored twice; loses faith | Implement escalation logic: recentCount >= 2 → escalatedCoaching |
| Coaching text says "improve" without specifics | Scores below 60 specificity pass the filter | Add specificity gate: filterBySpecificity(items, 60) |
| Resolved codes not surfaced | User improved but gets no acknowledgment | Compute and display resolvedCodes from history diff |
| Impact points missing from display | User can't prioritize — all items look equally important | Show ~{impactPoints} pts next to each item |
| Coaching item derived from wrong lane | "Add verification" shown for a Clarity failure | Verify laneAffected in failure code registry matches the judged lane |
| Expand/collapse rationale not keyboard accessible | Tab users can't read why the coaching matters | Add onKeyDown for Enter/Space on toggle button |
Coaching component crashes if items is empty | White box when no failure codes found | Render empty state: "No specific improvements flagged" |
| Coaching items ordered by lane order not impact | Lane #1 coaching shown even when Lane #3 has 3x the impact | Always sort by impactPoints descending before slicing |
Changelog
- 2026-03-31: Created for Bouts premium feedback system build