Reference Implementationsadvanced
Operations
interview-coach
Multi-turn interview coach: session start, score answers, generate questions, synthesize feedback. Demonstrates session + persistent memory and resource-backed state.
APIs Used
ctx.promptsctx.memory.sessionctx.memory.persistentctx.memory.executionctx.resourcesctx.llmctx.eventsctx.provenanceCapabilities Required
coaching/interview/conductcoaching/interview/assessWhat this demonstrates
- 1ctx.prompts.compose() for session-system + question-generate generative LLM call
- 2ctx.memory.session for active_session_id (hot lookup), ctx.memory.persistent for subject history
- 3ctx.memory.execution for quality_score per turn; ctx.resources for durable state and feedback
- 4Fourth Law: ctx.escalate() when two consecutive low scores
- 5ctx.events.emit('interview.session.completed') and provenance with cost per turn
Source
View on GitHubtypescript
/** * Interview Coach Agent — Reference Implementation * * Canon: kb/105_agent_sdk_architecture.md, kb/130_agent_design_patterns.md * Principles demonstrated: P1, P7, P10 (Human-in-Loop, Fourth Law) * * ─── Canon: Memory & state (KB 105 scope selection) ─────────────────────── * We do not use local variables or module-level state for anything that * crosses invocations or turns. All such state goes through SDK surfaces: * * • ctx.memory.execution — Single invocation only. Used for quality_score * this turn (scratch); never for session or cross-session data. * • ctx.memory.session — In-session only (session TTL). Used for * active_session_id so each turn does a fast lookup instead of querying * the Resource Graph. Not used for full Q&A state — that is in resources. * • ctx.memory.persistent — Cross-session, permanent. Used for * interview.subject.${subject_did} (prior history); read at session * start, written at session end. Not used for in-session scratch. * • ctx.resources — Authoritative durable state. agent.human.interview.state * holds questions_asked, current_question_index, etc.; loaded each turn, * saved each turn. Survives restarts and handoff. * * SDK surfaces demonstrated: * ctx.prompts.load() — answer-score, feedback-synthesize * ctx.prompts.compose() — session-system + question-generate for generative LLM call * ctx.resources.load() — agent.human.interview.state per turn * ctx.resources.save() — state + feedback (durable cross-turn) * ctx.llm.complete() — scoring (temp 0.2), question gen (temp 0.7), feedback (temp 0.3) * ctx.escalate() — Fourth Law when two consecutive quality_score < 0.4 * ctx.provenance.log() — per turn and session end, with cost * ctx.events.emit() — interview.session.completed at session end * ctx.memory.session — active_session_id (hot in-session lookup) * ctx.memory.persistent — subject prior interview history (cross-session) * ctx.memory.execution — quality_score per turn (single invocation) * ctx.interaction.openDuplex() — live voice when live_voice: true * * Event triggers: * interview.session.start — start session, optionally open duplex * interview.session.turn — score last answer, generate next question or finish * * This agent is a template for interactive, multi-turn agents that mix * session memory, persistent subject history, and durable resource state. */
import { handler, withProvenanceContext } from '@human/agent-sdk';import type { ExecutionContext } from '@human/agent-sdk';import { generateId } from '@human/core';
/** Start input (event: interview.session.start) */interface InterviewStartInput { event?: string; subject_did: string; topic: string; session_type?: 'technical' | 'behavioral' | 'case_study'; question_count?: number; live_voice?: boolean;}
/** Turn input (event: interview.session.turn) */interface InterviewTurnInput { event?: string; answer?: string;}
type InterviewInput = InterviewStartInput | InterviewTurnInput;
interface InterviewState { session_id: string; subject_did: string; topic: string; session_type: string; question_count: number; questions_asked: Array<{ question: string; answer?: string; score?: number }>; current_question_index: number; synthesis_notes?: string; started_at: string;}
const DEFAULT_QUESTION_COUNT = 5;const DEFAULT_SESSION_TYPE = 'technical';const LOW_SCORE_THRESHOLD = 0.4;const CONSECUTIVE_LOW_COUNT = 2;
function isStartInput(input: InterviewInput): input is InterviewStartInput { return 'subject_did' in input && 'topic' in input && !('answer' in input && input.answer !== undefined);}
const execute = async (ctx: ExecutionContext, input: unknown): Promise<Record<string, unknown>> => { const raw = input as InterviewInput; if (isStartInput(raw)) { return runSessionStart(ctx, raw as InterviewStartInput); } return runSessionTurn(ctx, raw as InterviewTurnInput);};
async function runSessionStart(ctx: ExecutionContext, input: InterviewStartInput): Promise<Record<string, unknown>> { const session_id = generateId('session'); const topic = input.topic; const session_type = (input.session_type ?? DEFAULT_SESSION_TYPE) as 'technical' | 'behavioral' | 'case_study'; const question_count = input.question_count ?? DEFAULT_QUESTION_COUNT; const live_voice = Boolean(input.live_voice); const subject_did = input.subject_did;
// Prior subject history from persistent memory (cross-session; canon: persistent = cross-session only) const priorHistoryRaw = await ctx.memory.persistent.get(`interview.subject.${subject_did}`); const priorHistory = priorHistoryRaw == null ? null : typeof priorHistoryRaw === 'string' ? (JSON.parse(priorHistoryRaw) as Record<string, unknown>) : (priorHistoryRaw as Record<string, unknown>);
const initialState: InterviewState = { session_id, subject_did, topic, session_type, question_count, questions_asked: [], current_question_index: 0, started_at: new Date().toISOString(), };
await ctx.resources.save('agent.human.interview.state', { data: initialState as unknown as Record<string, unknown>, uri: `resource://agent.human.interview.state/${subject_did}/${session_id}`, }); await ctx.memory.session.set('active_session_id', session_id);
if (live_voice) { const channel = await ctx.interaction.openDuplex({ mode: 'coach' }); channel.onEvent(async (event: { type: string; state?: string }) => { if (event.type === 'user.interrupt') await channel.stop(); if (event.type === 'session.state' && event.state === 'stopped') return; }); // Session runs until channel stops or timeout; turn handling would be event-driven // For this reference we log and return; full duplex flow would continue in channel events }
await ctx.provenance.log( withProvenanceContext(ctx, { action: 'interview.session.started', status: 'success', output: { session_id, subject_did, live_voice }, }) );
return { session_id, subject_did, topic, session_type, question_count, live_voice };}
async function runSessionTurn(ctx: ExecutionContext, input: InterviewTurnInput): Promise<Record<string, unknown>> { const session_id = await ctx.memory.session.get('active_session_id'); if (!session_id) { throw new Error('No active session; start a session first.'); }
const { data: stateList } = await ctx.resources.load('agent.human.interview.state', { limit: 10 }); const stateRecord = stateList.find((r) => (r.metadata as Record<string, unknown>)?.session_id === session_id); if (!stateRecord) { throw new Error(`No interview state found for session ${session_id}.`); } const state = stateRecord.metadata as unknown as InterviewState; const questions_asked = [...(state.questions_asked ?? [])]; const current_question_index = state.current_question_index ?? 0; const question_count = state.question_count ?? DEFAULT_QUESTION_COUNT; const topic = state.topic; const subject_did = state.subject_did;
// Score the answer we just received. current_question_index was already incremented // when we generated this question (previous turn), so the question the user just // answered is at index current_question_index - 1. const answer_question_index = current_question_index - 1; let quality_score = 0.5; if (input.answer != null && answer_question_index >= 0 && answer_question_index < questions_asked.length) { const currentQ = questions_asked[answer_question_index]; const scorePrompt = await ctx.prompts.load('core/agents/interview-coach/answer-score'); const scoreRendered = scorePrompt.render({ question: currentQ?.question ?? '', answer: input.answer, }); const scoreResult = await ctx.llm.complete({ prompt: scoreRendered, promptMetadata: scorePrompt.toCallMetadata(), temperature: 0.2, maxTokens: 300, }); try { const parsed = JSON.parse(scoreResult.content.replace(/^[\s\S]*?(\{[\s\S]*\})[\s\S]*$/m, '$1')) as { score?: number; reasoning?: string }; const rawScore = Number(parsed.score); quality_score = Number.isNaN(rawScore) ? 0.5 : Math.max(0, Math.min(1, rawScore)); } catch { quality_score = 0.5; } questions_asked[answer_question_index] = { ...currentQ!, answer: input.answer, score: quality_score, }; await ctx.memory.execution.set('quality_score', String(quality_score)); }
// Fourth Law: two consecutive low scores → escalate (scores already include current turn) const scoresSoFar = questions_asked.map((q) => q.score ?? 1); const lastTwoScores = scoresSoFar.slice(-2); const twoConsecutiveLow = lastTwoScores.length >= 2 && lastTwoScores.every((s) => s < LOW_SCORE_THRESHOLD); if (twoConsecutiveLow) { await ctx.escalate({ reason: 'low_engagement', context: { session_id, questions_asked: questions_asked.length, last_two_scores: lastTwoScores }, requiredCapability: 'coaching/interview/conduct', }); }
// Session end: we've asked question_count questions and just processed the last answer. // current_question_index was incremented after each question, so it equals question_count here. const sessionComplete = current_question_index === question_count; let updatedState: InterviewState; let nextQuestion = '';
if (sessionComplete) { updatedState = { ...state, questions_asked, current_question_index }; } else { // Generate next question (compose session-system + question-generate) const priorQA = questions_asked .map((q) => `Q: ${q.question}\nA: ${q.answer ?? '(pending)'}`) .join('\n\n'); const composed = await ctx.prompts.compose( [ 'core/agents/interview-coach/session-system', 'core/agents/interview-coach/question-generate', ], { variables: { topic, session_type: state.session_type ?? 'technical', question_count: String(question_count), priorQA: priorQA || '(none yet)', }, }, ); const questionResult = await ctx.llm.complete({ prompt: composed.content, promptMetadata: composed.metadata, temperature: 0.7, maxTokens: 200, }); try { const raw = questionResult.content.replace(/^[\s\S]*?(\{[\s\S]*\})[\s\S]*$/m, '$1'); const parsed = JSON.parse(raw) as { question?: string }; nextQuestion = String(parsed?.question ?? '').trim() || 'Tell me more about your experience in this area.'; } catch { nextQuestion = 'Tell me more about your experience in this area.'; }
questions_asked.push({ question: nextQuestion }); updatedState = { ...state, questions_asked, current_question_index: current_question_index + 1, }; await ctx.resources.save('agent.human.interview.state', { data: updatedState as unknown as Record<string, unknown>, uri: `resource://agent.human.interview.state/${subject_did}/${session_id}`, });
await ctx.provenance.log( withProvenanceContext(ctx, { action: 'interview.turn.completed', status: 'success', output: { session_id, question_index: current_question_index, quality_score }, cost: (questionResult as { cost?: { usd: number } }).cost, }) ); }
// Session end: synthesize feedback and emit event (no new question generated; all Qs answered) if (sessionComplete) { const feedbackPrompt = await ctx.prompts.load('core/agents/interview-coach/feedback-synthesize'); const qaText = updatedState.questions_asked .map((q, i) => `Q${i + 1}: ${q.question}\nA: ${q.answer ?? '(no answer)'}`) .join('\n\n'); const feedbackRendered = feedbackPrompt.render({ topic: updatedState.topic, session_type: updatedState.session_type ?? 'technical', questionsAndAnswers: qaText, }); const feedbackResult = await ctx.llm.complete({ prompt: feedbackRendered, promptMetadata: feedbackPrompt.toCallMetadata(), temperature: 0.3, maxTokens: 800, }); let feedbackPayload: Record<string, unknown> = { session_id, subject_did, topic: updatedState.topic, overall_assessment: '', strengths: [], development_areas: [], capability_signals: {}, confidence: 0.7, generated_by: ctx.agentId, provenance_ref: '', }; try { const parsed = JSON.parse(feedbackResult.content.replace(/^[\s\S]*?(\{[\s\S]*\})[\s\S]*$/m, '$1')) as Record<string, unknown>; feedbackPayload = { ...feedbackPayload, ...parsed, session_id, subject_did, topic: updatedState.topic, generated_by: ctx.agentId }; } catch { // keep defaults } const saveRes = await ctx.resources.save('agent.human.interview.feedback', { data: feedbackPayload }); await ctx.memory.persistent.set( `interview.subject.${subject_did}`, JSON.stringify({ last_session_id: session_id, feedback_ref: saveRes.resource_id, capability_signals: feedbackPayload.capability_signals, }), ); await ctx.events.emit('interview.session.completed', { session_id, subject_did, feedback_ref: saveRes.resource_id, }); await ctx.provenance.log( withProvenanceContext(ctx, { action: 'interview.session.completed', status: 'success', output: { session_id, subject_did }, cost: (feedbackResult as { cost?: { usd: number } }).cost, }) ); return { session_id, status: 'completed', feedback_ref: saveRes.resource_id }; }
return { session_id, status: 'turn', question_index: updatedState.current_question_index, next_question: nextQuestion, };}
export default handler({ name: 'interview-coach', id: 'interview-coach', version: '1.0.0', capabilities: ['coaching/interview/conduct', 'coaching/interview/assess'], manifest: { operations: [ { name: 'start', description: 'Start an interview session; optionally open live voice duplex', paramsSchema: { subject_did: { type: 'string', required: true }, topic: { type: 'string', required: true }, session_type: { type: 'string' }, question_count: { type: 'number' }, live_voice: { type: 'boolean' }, }, resultKind: 'agent.human.interview.session', }, { name: 'turn', description: 'Submit answer and get next question or final feedback', paramsSchema: { answer: { type: 'string' } }, resultKind: 'agent.human.interview.state', }, ], resources: { consumes: [{ kind: 'agent.human.interview.session' }, { kind: 'agent.human.interview.state' }], produces: [ { kind: 'agent.human.interview.session' }, { kind: 'agent.human.interview.state' }, { kind: 'agent.human.interview.feedback' }, ], }, on: [ { event: 'interview.session.start' }, { event: 'interview.session.turn' }, ], }, 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