The problem
An interviewer needs conversational continuity, while an evaluator needs enough distance to notice patterns across the whole session. Combining both roles in one prompt can make the interview inconsistent.
Run a multi-turn interview while a separate observer tracks the full conversation, then produce a structured debrief.
Useful forTeams prototyping interview practice and structured coaching
An interviewer needs conversational continuity, while an evaluator needs enough distance to notice patterns across the whole session. Combining both roles in one prompt can make the interview inconsistent.
Bundled materials can be replaced with a resume, project notes, code, and job description from your own directory.
Each specialist sees the evidence it needs. Independent work runs together; dependent work waits for the right inputs.
The interviewer retains state across turns and receives the candidate’s live terminal input.
Asks personalized follow-ups based on prior answers and candidate materials.
A stateless observer rereads the complete transcript after each exchange.
Records patterns and coaching signals without taking over the conversation.
The full session is converted into a schema-validated result.
Produces structured feedback at the end of the interview loop.
The conversation stays human-led while the observer provides a second perspective over the complete transcript.
The business view above is editorial. The commands, prerequisites, APIs, and source below stay synchronized with the real repository.
AgentSharedMemoryToolExecutorToolRegistry From a clone of the repo — this exact file:
npx tsx packages/core/examples/cookbook/personalized-interview-simulator.ts
OMA is provider-agnostic — this example is written for the key above, but you can run it on OpenAI, Gemini, Groq and others. All providers →
The complete example, synchronized from the pinned Framework commit.
/*** 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 packages/core/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_DIRconst MAX_TURNS = 10function 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<typeof DebriefSchema>// ---------------------------------------------------------------------------// 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<typeof createInterface>): Promise<string> {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 turnRules:- 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 farWrite 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 answerOutput 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 summaryReturn 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 = 0try {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 Debriefconsole.log(JSON.stringify(debrief, null, 2))console.log()console.log(`Turns completed: ${turnsCompleted}`)console.log('Done.')
open-multi-agent is MIT-licensed and free to run yourself. When you need it delivered, integrated, or supported on a deadline, 元定义科技 (YuanASI) offers commercial delivery and support.