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 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 user
navigate 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