Reference Implementations

Operations

interview-coach

advanced

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

Capabilities Required

coaching/interview/conductcoaching/interview/assess

What 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
typescript
/**
* 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