Reference Implementations

Interaction

duplex-coach

advanced

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.provenance

Capabilities Required

interaction/coach

What 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
typescript
/**
* 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 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 systemPrompt = await ctx.prompts.load('core/agents/duplex-coach/system');
const result = await ctx.llm.complete({
system: systemPrompt.content,
prompt: `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,
promptMetadata: systemPrompt.toCallMetadata(),
});
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