/** * Personalized Interview Simulator (Interviewer + Observer) * * Demonstrates: * - A stateful `interviewer` agent using `Agent.prompt()` across turns * - A stateless `observer` agent that reads full transcript context between turns * - Manual `SharedMemory` seeding and prompt injection outside `runTeam()` / `runTasks()` * - Human input handled at app level with `readline` * - Structured debrief via Zod at the end of the interview loop * * Run: * npx tsx examples/cookbook/personalized-interview-simulator.ts * * Prerequisites: * ANTHROPIC_API_KEY env var must be set. * * Optional: * Set INTERVIEW_CANDIDATE_DIR to point at your own materials directory. * Expected files: * - resume.md * - project-notes.md * - code.ts * - job-description.md */ import { readFileSync } from 'node:fs' import path from 'node:path' import { createInterface } from 'node:readline/promises' import { stdin as input, stdout as output } from 'node:process' import { fileURLToPath } from 'node:url' import { z } from 'zod' import { Agent, SharedMemory, ToolExecutor, ToolRegistry } from '../../src/index.js' import type { AgentConfig } from '../../src/types.js' // --------------------------------------------------------------------------- // Candidate materials // --------------------------------------------------------------------------- const __dirname = path.dirname(fileURLToPath(import.meta.url)) const DEFAULT_CANDIDATE_DIR = path.join(__dirname, '../fixtures/interview-candidate') const CANDIDATE_DIR = process.env.INTERVIEW_CANDIDATE_DIR ?? DEFAULT_CANDIDATE_DIR const MAX_TURNS = 10 function loadText(fileName: string): string { return readFileSync(path.join(CANDIDATE_DIR, fileName), 'utf-8').trim() } const resumeText = loadText('resume.md') const projectNotes = loadText('project-notes.md') const codeSnippet = loadText('code.ts') const jobDescription = loadText('job-description.md') // --------------------------------------------------------------------------- // Structured debrief // --------------------------------------------------------------------------- const DebriefSchema = z.object({ questions_asked: z.array( z.object({ question: z.string(), why_it_mattered: z.string(), }), ), weak_spots: z.array(z.string()), strong_spots: z.array(z.string()), overall_assessment: z.object({ recommendation: z.enum(['strong-hire', 'hire', 'lean-hire', 'lean-no-hire', 'no-hire']), summary: z.string(), }), }) type Debrief = z.infer // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function buildAgent(config: AgentConfig): Agent { const registry = new ToolRegistry() const executor = new ToolExecutor(registry) return new Agent(config, registry, executor) } async function readlineFromCandidate(prompt: string, rl: ReturnType): Promise { console.log('\n' + '='.repeat(60)) console.log('INTERVIEWER') console.log('='.repeat(60)) console.log(prompt) console.log() const answer = await rl.question('Candidate > ') return answer.trim() } function isExitAnswer(answer: string): boolean { const normalized = answer.trim().toLowerCase() return normalized === 'exit' || normalized === 'quit' } // --------------------------------------------------------------------------- // Agent configs // --------------------------------------------------------------------------- if (!process.env.ANTHROPIC_API_KEY) { console.log('[skip] This example needs ANTHROPIC_API_KEY.') process.exit(0) } const interviewerConfig: AgentConfig = { name: 'interviewer', provider: 'anthropic', model: 'claude-sonnet-4-6', systemPrompt: `You are a senior technical interviewer. You are running a personalized interview grounded in: - the candidate's resume - project notes - a code sample - the target job description - observer flags written after each turn Rules: - Ask exactly one question per turn. - Prefer probing follow-ups over switching topics too early. - Use the role expectations and candidate materials together. - If the observer flags a contradiction, vague answer, or missing dimension, use that. - Keep each question concise but sharp. - Do not answer your own question. - Do not output analysis, just the next interview question.`, maxTurns: 2, temperature: 0.3, } const observerConfig: AgentConfig = { name: 'observer', provider: 'anthropic', model: 'claude-sonnet-4-6', systemPrompt: `You are an interview observer. You will receive shared memory containing: - candidate resume - project notes - code sample - target job spec - the full interview transcript so far Write compact flags for the next interviewer turn. Focus on: - contradictions with candidate claims - vague or incomplete answers - important dimensions not yet tested - specific follow-up hooks hidden in the answer Output 3-6 short bullets. Be concrete. No prose introduction.`, maxTurns: 1, temperature: 0.2, } const reporterConfig: AgentConfig = { name: 'reporter', provider: 'anthropic', model: 'claude-sonnet-4-6', systemPrompt: `You are writing a compact interview debrief. Use the complete interview record to produce: - questions_asked: the most important questions and why each mattered - weak_spots: concise bullets for weak areas - strong_spots: concise bullets for strengths - overall_assessment: recommendation plus a short summary Return JSON only, matching the schema exactly.`, maxTurns: 2, temperature: 0.1, outputSchema: DebriefSchema, } // --------------------------------------------------------------------------- // Seed memory // --------------------------------------------------------------------------- const mem = new SharedMemory() await mem.write('candidate', 'resume', resumeText) await mem.write('candidate', 'project-notes', projectNotes) await mem.write('candidate', 'code', codeSnippet) await mem.write('role', 'target-job-spec', jobDescription) const interviewer = buildAgent(interviewerConfig) const observer = buildAgent(observerConfig) const reporter = buildAgent(reporterConfig) const rl = createInterface({ input, output }) console.log('Personalized Interview Simulator') console.log('='.repeat(60)) console.log(`Candidate materials: ${CANDIDATE_DIR}`) console.log('Type "exit" or "quit" to stop early.') console.log() // --------------------------------------------------------------------------- // Interactive loop // --------------------------------------------------------------------------- let answer = '' let turnsCompleted = 0 try { for (let turn = 0; turn < MAX_TURNS; turn++) { const ctx = await mem.getSummary() const result = await interviewer.prompt( turn === 0 ? [ `Context:\n${ctx}`, 'Ask the most probing opening question you can justify from the materials and role.', ].join('\n\n') : [ `Context:\n${ctx}`, `Candidate answer:\n${answer}`, 'Ask the next question.', ].join('\n\n'), ) if (!result.success) { console.error('Interviewer failed:', result.output) process.exit(1) } answer = await readlineFromCandidate(result.output, rl) if (isExitAnswer(answer)) { console.log('\nInterview stopped by candidate.') break } turnsCompleted++ await mem.write('interviewer', `turn-${turn}`, `Q: ${result.output}\nA: ${answer}`) const observerResult = await observer.run( [ 'Review transcript and candidate materials. Write flags for the next turn.', await mem.getSummary(), ].join('\n\n'), ) if (!observerResult.success) { console.error('Observer failed:', observerResult.output) process.exit(1) } await mem.write('observer', 'flags', observerResult.output) } } finally { rl.close() } // --------------------------------------------------------------------------- // Structured debrief // --------------------------------------------------------------------------- console.log('\n' + '='.repeat(60)) console.log('DEBRIEF') console.log('='.repeat(60)) const debriefResult = await reporter.run(`Summarize the full interview:\n\n${await mem.getSummary()}`) if (!debriefResult.success || !debriefResult.structured) { console.error('Debrief generation failed:', debriefResult.output) process.exit(1) } const debrief = debriefResult.structured as Debrief console.log(JSON.stringify(debrief, null, 2)) console.log() console.log(`Turns completed: ${turnsCompleted}`) console.log('Done.')