Reference Implementationsadvanced
Interaction
duplex-coach
Real-time meeting coach using DCCP duplex channel. Whispers actionable insights into the user's ear without interrupting their flow.
APIs Used
ctx.interactionctx.llmctx.provenanceCapabilities Required
interaction/coachWhat this demonstrates
- 1ctx.interaction.openDuplex() in coach mode — event-driven, not polling
- 2Handling user.final_text → LLM analysis → channel.speak() (whisper style)
- 3Handling user.interrupt for graceful barge-in stop
- 4Handling signal.emotion for escalation logic
- 5Provenance logging of duplex interaction sessions
Source
View on GitHubtypescript
/** * Duplex Coach — DCCP Reference Agent * * Canon alignment: * - Implements DCCP pattern: plans/149_duplex_conversational_channel_pattern_prd.md §6.4 + §9.1 * - This is the developer reference for any agent that wants to use ctx.interaction.openDuplex(). * - Companion will follow this pattern when built. * * Demonstrates: * - ctx.interaction.openDuplex() in 'coach' mode * - Handling user.final_text → LLM analysis → channel.speak() (whisper style) * - Handling user.interrupt → graceful stop * - Handling signal.emotion → escalation logic * - Provenance logging of duplex interactions * * Capabilities declared: interaction.duplex.session.start, interaction.duplex.speak, * interaction.duplex.backchannel, interaction.duplex.session.stop * * Interaction muscle pattern (PRD §5.4): * - Session lifecycle managed entirely by the channel * - Agent logic is event-driven, not polling * - Graceful barge-in: user.interrupt immediately stops agent speech */
import { handler, withProvenanceContext } from '@human/agent-sdk';import type { ExecutionContext } from '@human/agent-sdk';import type { DuplexChannel, DuplexEvent } from '@human/interaction';
export const AGENT_ID = 'duplex-coach';export const VERSION = '1.0.0';export const CAPABILITIES = [ 'interaction.duplex.session.start', 'interaction.duplex.session.stop', 'interaction.duplex.speak', 'interaction.duplex.backchannel', 'interaction/coach',];
export interface DuplexCoachInput { /** Override the interaction mode. Default: 'coach'. */ mode?: 'passive' | 'coach' | 'active'; /** Voice provider to use. Default: 'mock'. */ provider?: string; /** Maximum session duration in seconds. Default: 300 (5 min). */ maxDurationSecs?: number;}
export interface DuplexCoachOutput { success: boolean; sessionId: string; turnCount: number; stoppedReason: 'interrupt' | 'max_duration' | 'error' | 'natural_end'; provenance_id: string;}
const COACHING_SYSTEM_PROMPT = `You are a real-time meeting coach speaking into the user's ear.Your job: deliver short, actionable insights (1-2 sentences max) that help the usernavigate the conversation they are in. Be direct and specific. Never be verbose.When you have nothing useful to say, stay silent (return empty string).`;
const execute = async ( ctx: ExecutionContext, input: DuplexCoachInput,): Promise<DuplexCoachOutput> => { const mode = input.mode ?? 'coach'; const provider = input.provider ?? 'mock'; const maxDurationSecs = input.maxDurationSecs ?? 300;
ctx.log.info('Opening duplex coaching session', { mode, provider });
let sessionId = 'unknown'; let turnCount = 0; let stoppedReason: DuplexCoachOutput['stoppedReason'] = 'natural_end';
let channel: DuplexChannel | null = null;
try { // ── 1. Open the duplex channel ────────────────────────────────────────── channel = await ctx.interaction.openDuplex({ mode, provider }); sessionId = channel.sessionId;
ctx.log.info('Duplex channel opened', { sessionId });
// ── 2. Set up event-driven coaching loop ──────────────────────────────── const channelDone = new Promise<'interrupt' | 'natural_end'>((resolve) => { channel!.onEvent(async (event: DuplexEvent) => { try { await handleEvent(ctx, channel!, event, () => { turnCount++; }); } catch (err) { ctx.log.warn('Error in duplex event handler', { event: event.type, error: err }); }
if (event.type === 'user.interrupt') { resolve('interrupt'); } else if (event.type === 'session.state' && event.state === 'stopped') { resolve('natural_end'); } }); });
// ── 3. Race: channel done vs. max duration timeout ─────────────────────── const timeout = new Promise<'max_duration'>((resolve) => setTimeout(() => resolve('max_duration'), maxDurationSecs * 1000), );
const reason = await Promise.race([channelDone, timeout]); stoppedReason = reason;
if (reason === 'max_duration') { ctx.log.info('Max session duration reached — stopping duplex channel', { sessionId, maxDurationSecs, }); await channel.stop(); } } catch (err) { ctx.log.error('Duplex coach error', { error: err }); stoppedReason = 'error';
// Best-effort cleanup if (channel) { try { await channel.stop(); } catch { /* swallowed */ } } }
// ── 4. Record provenance ────────────────────────────────────────────────── const provenanceId = await ctx.provenance.log( withProvenanceContext(ctx, { action: 'interaction.duplex.session.completed', status: 'success', input: { mode, provider, maxDurationSecs }, output: { sessionId, turnCount, stoppedReason }, }) );
return { success: stoppedReason !== 'error', sessionId, turnCount, stoppedReason, provenance_id: provenanceId, };};
// ─── Event handler ─────────────────────────────────────────────────────────────
async function handleEvent( ctx: ExecutionContext, channel: DuplexChannel, event: DuplexEvent, onTurn: () => void,): Promise<void> { switch (event.type) { case 'session.state': ctx.log.info('Session state changed', { state: event.state, sessionId: event.sessionId }); break;
case 'user.partial_text': // Partial text — send a "thinking" backchannel to show we are processing if (event.confidence > 0.7) { await channel.backchannel('thinking'); } break;
case 'user.final_text': { onTurn(); // Analyze the utterance and provide a coaching nudge if relevant const insight = await analyzeUtterance(ctx, event.text); if (insight) { await channel.speak(insight, { style: 'whisper' }); } else { await channel.backchannel('ack'); } break; }
case 'user.interrupt': // Human barged in — stop immediately (PRD §3, "Human control first") ctx.log.info('User interrupted — stopping duplex channel'); await channel.stop(); break;
case 'signal.emotion': // Escalate on high stress + frustration (PRD §9.1 Decision guardrails) if ( (event.state === 'stressed' || event.state === 'frustrated') && event.confidence > 0.75 ) { await channel.speak( 'You sound a bit under pressure — take a breath, you have this.', { style: 'whisper' }, ); } break;
case 'companion.speaking': // Agent speech confirmation — nothing to do break; }}
async function analyzeUtterance(ctx: ExecutionContext, text: string): Promise<string | null> { // Skip very short utterances if (text.trim().length < 10) return null;
try { const result = await ctx.llm.complete({ prompt: [ { role: 'system', content: COACHING_SYSTEM_PROMPT }, { role: 'user', content: `The person in the meeting just said: "${text}"\n\nProvide a 1-2 sentence whispered coaching insight, or return empty string if nothing useful to add.`, }, ], temperature: 0.4, maxTokens: 80, });
const trimmed = result.content.trim(); return trimmed.length > 0 ? trimmed : null; } catch { return null; }}
export default handler({ name: AGENT_ID, id: AGENT_ID, version: VERSION, capabilities: CAPABILITIES, description: 'Real-time coaching agent that uses a duplex channel to whisper insights to the user during meetings and conversations.', manifest: { operations: [ { name: 'coach', description: 'Open duplex coaching session and deliver real-time insights via voice', paramsSchema: { mode: { type: 'string', description: 'passive | coach | active' }, provider: { type: 'string', description: 'Voice provider (default: mock)' }, maxDurationSecs: { type: 'number', description: 'Max session duration in seconds' }, }, resultKind: 'agent.duplex-coach.result', }, ], }, execute,});Run the tests
From monorepo root
$ pnpm test:agents:reference
$ pnpm test:agents:reference:verbose
The reference suite runs all 23 agents with createMockExecutionContext(), verifying every ctx.* API call and output shape.
See Also
SDK Reference
Patterns