Skip to main content

Research Synthesizer

A research tool with a multi-stage workflow: collect sources, analyze each in parallel, synthesize findings, and wait for human approval before finalizing. The number of analysis streams is determined at runtime based on how many sources the user adds.

Server Code

import { AgenticSystem, field, action, stream, StreamableValue } from 'idyllic';

interface Source {
  id: string;
  content: string;
  analysis: StreamableValue<string>;
}

export default class ResearchSynthesizer extends AgenticSystem {
  @field sources: Source[] = [];
  @field synthesis = stream<string>('');
  @field stage: 'collecting' | 'analyzing' | 'synthesizing' | 'reviewing' | 'complete' = 'collecting';
  @field approval: { pending: boolean; approved?: boolean } | null = null;

  @action()
  async addSource(content: string) {
    this.sources.push({
      id: crypto.randomUUID(),
      content,
      analysis: stream<string>(''),
    });
  }

  @action()
  async removeSource(id: string) {
    this.sources = this.sources.filter(s => s.id !== id);
  }

  @action()
  async analyze() {
    this.stage = 'analyzing';

    // Analyze all sources in parallel
    await Promise.all(
      this.sources.map(source => this.analyzeSource(source))
    );

    this.stage = 'synthesizing';
    await this.synthesize();
  }

  private async analyzeSource(source: Source) {
    for await (const chunk of ai.stream(
      `Analyze this source. Extract key claims and evidence:\n\n${source.content}`
    )) {
      source.analysis.append(chunk);
    }
    source.analysis.complete();
  }

  private async synthesize() {
    const analyses = this.sources
      .map((s, i) => `Source ${i + 1}:\n${s.analysis.current}`)
      .join('\n\n---\n\n');

    for await (const chunk of ai.stream(
      `Synthesize these analyses. Identify agreements, contradictions, and gaps:\n\n${analyses}`
    )) {
      this.synthesis.append(chunk);
    }
    this.synthesis.complete();

    this.stage = 'reviewing';
    this.approval = { pending: true };
  }

  @action()
  async approve(approved: boolean) {
    this.approval = { pending: false, approved };

    if (approved) {
      this.stage = 'complete';
    } else {
      this.stage = 'collecting';
      this.synthesis.reset();
      this.approval = null;
    }
  }
}

Key Patterns

Dynamic stream arrays: Each source contains its own analysis stream. When you push a source to the array, the stream syncs to clients. During analysis, each stream updates in parallel. Human-in-the-loop: After synthesis, set approval = { pending: true } and return. The Durable Object can hibernate. When the user responds, the approve action continues the workflow. This approval state persists across browser sessions.

Client Code

import { useSystem } from '@idyllic/react';
import type { ResearchSynthesizer } from '../systems/ResearchSynthesizer';

export default function ResearchUI() {
  const { sources, synthesis, stage, approval, addSource, removeSource, analyze, approve } = useSystem<ResearchSynthesizer>();

  return (
    <div className="max-w-4xl mx-auto p-6">
      <StageIndicator stage={stage} />

      <SourceInput onAdd={addSource} disabled={stage !== 'collecting'} />

      <div className="space-y-4 my-6">
        {sources.map(source => (
          <SourceCard
            key={source.id}
            source={source}
            onRemove={() => removeSource(source.id)}
            showAnalysis={stage !== 'collecting'}
          />
        ))}
      </div>

      {stage === 'collecting' && sources.length > 0 && (
        <button onClick={analyze} className="w-full py-3 bg-blue-600 text-white rounded">
          Analyze {sources.length} Sources
        </button>
      )}

      {['synthesizing', 'reviewing', 'complete'].includes(stage) && (
        <SynthesisPanel content={synthesis.current} streaming={stage === 'synthesizing'} />
      )}

      {approval?.pending && (
        <ApprovalDialog
          onApprove={() => approve(true)}
          onReject={() => approve(false)}
        />
      )}

      {stage === 'complete' && (
        <div className="mt-4 p-4 bg-green-50 border border-green-200 rounded">
          Research complete and approved.
        </div>
      )}
    </div>
  );
}

function StageIndicator({ stage }: { stage: string }) {
  const stages = ['collecting', 'analyzing', 'synthesizing', 'reviewing', 'complete'];
  const current = stages.indexOf(stage);

  return (
    <div className="flex gap-2 mb-6">
      {stages.map((s, i) => (
        <div key={s} className={`flex-1 h-2 rounded ${i <= current ? 'bg-blue-600' : 'bg-gray-200'}`} />
      ))}
    </div>
  );
}

function SourceCard({ source, onRemove, showAnalysis }) {
  return (
    <div className="border rounded p-4">
      <div className="flex justify-between mb-2">
        <span className="text-sm text-gray-500">Source</span>
        <button onClick={onRemove} className="text-red-500 text-sm">Remove</button>
      </div>
      <p className="text-sm mb-2 line-clamp-3">{source.content}</p>
      {showAnalysis && source.analysis.current && (
        <div className="mt-3 pt-3 border-t">
          <span className="text-sm text-gray-500">Analysis</span>
          <p className="text-sm mt-1 whitespace-pre-wrap">{source.analysis.current}</p>
        </div>
      )}
    </div>
  );
}

function ApprovalDialog({ onApprove, onReject }) {
  return (
    <div className="fixed inset-0 bg-black/50 flex items-center justify-center">
      <div className="bg-white rounded-lg p-6 max-w-md">
        <h3 className="text-lg font-medium mb-2">Review Synthesis</h3>
        <p className="text-gray-600 mb-4">Does this synthesis accurately represent the sources?</p>
        <div className="flex gap-3">
          <button onClick={onApprove} className="flex-1 py-2 bg-green-600 text-white rounded">Approve</button>
          <button onClick={onReject} className="flex-1 py-2 bg-gray-200 rounded">Revise</button>
        </div>
      </div>
    </div>
  );
}

Running the Example

npx idyllic dev
Add 2-3 sources, then click “Analyze Sources.” Watch each source’s analysis stream in parallel. When synthesis finishes, the approval dialog appears. Approve to complete, or reject to return to collection. Key behaviors to notice:
  • Dynamic streams: Add 5 sources and 5 parallel streams appear
  • Workflow stages: The stage field drives which UI renders
  • Persistent approval: Close your browser, return later—the approval dialog is still waiting
  • Clean rejection loop: Rejecting resets to collection stage for iteration