Back to home

02 — TOOL CREATION

Progress reporting on the frontend

16 min read

Your agent streams events from the backend. The frontend receives them. Now what?

Events sitting in a console log don't help users. They need to see "Creating src/app.ts...", watch code appear in an editor, see progress bars fill up, and get immediate error notifications when something fails.

This is about translating events into UI updates. File operations update the file tree. Code chunks stream into editors. Step events fill progress lists. Errors show notifications. The UI becomes a real-time reflection of what the agent is doing.

Why Frontend Progress Matters

Without progress reporting, users assume the worst. A 30-second operation feels like forever when there's no feedback. They refresh the page, cancel the operation, or assume something broke.

With progress reporting:

  • Users stay informed - They see "Installing packages...", "Setting up database...", "Writing code..."
  • Operations feel faster - Seeing progress makes time pass quickly
  • Errors are immediate - No waiting for completion to see what failed
  • Context is preserved - Users understand what succeeded before an error occurred
  • Trust increases - Visibility builds confidence in the system

Good progress reporting turns a black box into a transparent, predictable experience.

Connecting to SSE Streams

Use the EventSource API to connect to Server-Sent Events:

const projectId = 'abc123';
const messageId = 'msg456';
 
const eventSource = new EventSource(
  `/api/agents/${projectId}/chat?messageId=${messageId}`
);
 
eventSource.onopen = () => {
  console.log('Connected to agent stream');
};
 
eventSource.onmessage = (e) => {
  const event = JSON.parse(e.data);
  handleEvent(event);
};
 
eventSource.onerror = (error) => {
  console.error('SSE error:', error);
  eventSource.close();
};

EventSource automatically handles reconnection if the connection drops. It's built for long-lived streams.

When you're done:

eventSource.close();

This closes the connection and stops receiving events.

Event Message Structure

Every event has the same structure:

interface AgentEvent {
  type: string;
  data?: any;
}

The type follows the pattern: <agent>.<operation>.<lifecycle_stage>

Examples:

  • coding_agent.create_file.started
  • coding_agent.edit_file.chunk
  • database_agent.step.completed
  • design_system_agent.generate_design_system.done

The data field contains operation-specific information:

{
  type: 'coding_agent.create_file.started',
  data: {
    file_path: 'src/app.ts'
  }
}
 
{
  type: 'coding_agent.create_file.chunk',
  data: {
    file_path: 'src/app.ts',
    chunk: 'export function hello() {\n  return "world";\n}'
  }
}
 
{
  type: 'coding_agent.create_file.completed',
  data: {
    file_path: 'src/app.ts'
  }
}

Basic Event Handler

Route events to different handlers based on type:

function handleEvent(event: AgentEvent) {
  const { type, data } = event;
 
  // Initialization
  if (type === 'init') {
    showMessage(data.message);
    return;
  }
 
  // Completion
  if (type === 'complete') {
    hideLoadingIndicator();
    showMessage('Agent finished');
    return;
  }
 
  // File operations
  if (type.includes('.create_file.')) {
    handleFileCreate(type, data);
    return;
  }
 
  if (type.includes('.edit_file.')) {
    handleFileEdit(type, data);
    return;
  }
 
  if (type.includes('.delete_file.')) {
    handleFileDelete(type, data);
    return;
  }
 
  // Step-based progress
  if (type.includes('.step.')) {
    handleStepProgress(type, data);
    return;
  }
 
  // Errors
  if (type.endsWith('.error')) {
    handleError(type, data);
    return;
  }
}

This routing pattern keeps event handling organized as you add more event types.

File Operation Events

File operations follow a three-phase lifecycle: started → chunk → completed.

Handling File Creation

const fileOperations = new Map<string, FileOperation>();
 
function handleFileCreate(type: string, data: any) {
  const { file_path, chunk, error } = data;
 
  if (type.endsWith('.started')) {
    // Show loading state in file tree
    fileOperations.set(file_path, {
      type: 'create',
      status: 'in_progress',
      path: file_path
    });
 
    updateFileTree(file_path, { loading: true });
    showNotification(`Creating ${file_path}...`, 'info');
  }
 
  if (type.endsWith('.chunk')) {
    // Stream code into editor
    const editor = getOrCreateEditor(file_path);
    editor.setValue(chunk);
 
    // Update syntax highlighting as code appears
    editor.refresh();
  }
 
  if (type.endsWith('.completed')) {
    if (error) {
      // Show error state
      updateFileTree(file_path, { loading: false, error: true });
      showNotification(`Failed to create ${file_path}: ${error}`, 'error');
    } else {
      // Show success state
      updateFileTree(file_path, { loading: false, created: true });
      showNotification(`Created ${file_path}`, 'success');
    }
 
    fileOperations.delete(file_path);
  }
 
  if (type.endsWith('.error')) {
    // Handle error
    updateFileTree(file_path, { loading: false, error: true });
    showNotification(`Error creating ${file_path}: ${error}`, 'error');
    fileOperations.delete(file_path);
  }
}

UI updates:

  1. Started: File tree shows loading spinner next to new file
  2. Chunk: Editor shows code appearing in real-time
  3. Completed: Loading spinner becomes checkmark
  4. Error: Loading spinner becomes error icon, notification explains why

Handling File Edits

File edits include additional metadata like line changes:

function handleFileEdit(type: string, data: any) {
  const { file_path, chunk, additions, deletions, old_code, new_code } = data;
 
  if (type.endsWith('.started')) {
    showNotification(`Editing ${file_path}...`, 'info');
    highlightFileInTree(file_path, 'editing');
  }
 
  if (type.endsWith('.chunk')) {
    // Show diff view while editing
    const editor = getEditor(file_path);
    if (editor) {
      editor.setValue(chunk);
      editor.refresh();
    }
  }
 
  if (type.endsWith('.completed')) {
    // Show statistics
    if (additions || deletions) {
      showNotification(
        `Updated ${file_path} (+${additions}, -${deletions})`,
        'success'
      );
    }
 
    // Offer to show diff
    if (old_code && new_code) {
      showDiffButton(file_path, old_code, new_code);
    }
 
    highlightFileInTree(file_path, 'modified');
  }
}

The additions/deletions counts give users quick insight into the scope of changes.

Handling File Deletion

function handleFileDelete(type: string, data: any) {
  const { file_path } = data;
 
  if (type.endsWith('.started')) {
    showNotification(`Deleting ${file_path}...`, 'info');
  }
 
  if (type.endsWith('.completed')) {
    // Remove from file tree
    removeFromFileTree(file_path);
 
    // Close editor if open
    const editor = getEditor(file_path);
    if (editor) {
      closeEditor(file_path);
    }
 
    showNotification(`Deleted ${file_path}`, 'success');
  }
}

Streaming Code to Editors

For code generation, stream chunks character by character:

const editorBuffers = new Map<string, string>();
 
function handleCodeChunk(filePath: string, chunk: string) {
  // Accumulate chunks
  const buffer = editorBuffers.get(filePath) || '';
  const updated = buffer + chunk;
  editorBuffers.set(filePath, updated);
 
  // Update editor
  const editor = getOrCreateEditor(filePath);
  editor.setValue(updated);
 
  // Scroll to cursor position (show latest code)
  editor.scrollIntoView({ line: editor.lineCount(), ch: 0 });
 
  // Update character count in status bar
  updateStatusBar(filePath, {
    characters: updated.length,
    lines: updated.split('\n').length
  });
}
 
function handleCodeComplete(filePath: string) {
  // Clear buffer
  editorBuffers.delete(filePath);
 
  // Final editor state
  const editor = getEditor(filePath);
  if (editor) {
    editor.refresh();
    editor.clearSelection();
  }
 
  // Enable editing (was read-only during streaming)
  editor.setOption('readOnly', false);
}

This creates a typewriter effect where users see code appearing in real-time.

Step-Based Progress

Step events track multi-phase operations:

interface Step {
  title: string;
  status: 'pending' | 'in_progress' | 'completed' | 'failed';
  step_type: string;
  error?: string;
  details?: any;
}
 
const steps = new Map<string, Step>();
 
function handleStepProgress(type: string, data: any) {
  const { title, status, step_type, error, details } = data;
 
  if (type.endsWith('.started')) {
    steps.set(title, {
      title,
      status: 'in_progress',
      step_type
    });
 
    showNotification(`${title}...`, 'info');
  }
 
  if (type.endsWith('.completed')) {
    steps.set(title, {
      title,
      status: 'completed',
      step_type,
      details
    });
 
    showNotification(`✓ ${title}`, 'success');
  }
 
  if (type.endsWith('.failed')) {
    steps.set(title, {
      title,
      status: 'failed',
      step_type,
      error
    });
 
    showNotification(`✗ ${title}: ${error}`, 'error');
  }
 
  // Update step list UI
  renderStepList(Array.from(steps.values()));
}

Render steps as a progress list:

function StepList({ steps }: { steps: Step[] }) {
  return (
    <div className="space-y-2">
      {steps.map((step) => (
        <div key={step.title} className="flex items-center gap-2">
          {step.status === 'in_progress' && (
            <Spinner className="w-4 h-4 text-blue-500" />
          )}
          {step.status === 'completed' && (
            <CheckIcon className="w-4 h-4 text-green-500" />
          )}
          {step.status === 'failed' && (
            <XIcon className="w-4 h-4 text-red-500" />
          )}
 
          <span className={step.status === 'failed' ? 'text-red-600' : ''}>
            {step.title}
          </span>
 
          {step.error && (
            <Tooltip content={step.error}>
              <InfoIcon className="w-4 h-4 text-red-400" />
            </Tooltip>
          )}
        </div>
      ))}
    </div>
  );
}

Users see a live-updating checklist of what the agent is doing.

Progress Indicators

For operations with known steps, show progress percentage:

function handleProgressEvent(type: string, data: any) {
  const { progress, current, total, step } = data;
 
  // Update progress bar
  updateProgressBar(progress);
 
  // Update step counter
  updateStepCounter(current, total);
 
  // Show current step
  showMessage(`Step ${current} of ${total}: ${step}`);
}

Render as a progress bar:

function ProgressBar({ progress, current, total, step }: ProgressBarProps) {
  return (
    <div className="space-y-2">
      <div className="flex justify-between text-sm">
        <span>{step}</span>
        <span>{current} / {total}</span>
      </div>
 
      <div className="w-full bg-gray-200 rounded-full h-2">
        <div
          className="bg-blue-500 h-2 rounded-full transition-all duration-300"
          style={{ width: `${progress}%` }}
        />
      </div>
 
      <div className="text-xs text-gray-500 text-center">
        {Math.round(progress)}% complete
      </div>
    </div>
  );
}

Error Notifications

Show errors immediately when they occur:

function handleError(type: string, data: any) {
  const { error, file_path, command, details } = data;
 
  // Extract operation from type
  const operation = type.split('.').slice(1, -1).join(' ');
 
  // Build error message
  let message = `Error during ${operation}`;
  if (file_path) message += ` on ${file_path}`;
  if (command) message += ` running "${command}"`;
  message += `: ${error}`;
 
  // Show notification
  showNotification(message, 'error', {
    duration: 10000, // Errors stay longer
    dismissible: true,
    actions: [
      {
        label: 'Copy Error',
        onClick: () => navigator.clipboard.writeText(error)
      },
      details && {
        label: 'View Details',
        onClick: () => showErrorModal(error, details)
      }
    ].filter(Boolean)
  });
 
  // Log to error tracking
  logError({
    type,
    error,
    context: { file_path, command, details }
  });
}

Error notifications include:

  • What operation failed
  • What resource it was operating on
  • The actual error message
  • Actions (copy error, view details)

Event Filtering Patterns

Filter events by patterns to route them efficiently:

function handleEvent(event: AgentEvent) {
  const { type, data } = event;
 
  // File operations (any lifecycle stage)
  if (type.includes('.create_file.') ||
      type.includes('.edit_file.') ||
      type.includes('.delete_file.') ||
      type.includes('.read_file.')) {
    handleFileOperation(type, data);
    return;
  }
 
  // Terminal/shell operations
  if (type.includes('.run_shell_command.') ||
      type.includes('.run_terminal_command.')) {
    handleTerminalOperation(type, data);
    return;
  }
 
  // Search operations
  if (type.includes('.glob_search.') ||
      type.includes('.grep_search.') ||
      type.includes('.list_dir.')) {
    handleSearchOperation(type, data);
    return;
  }
 
  // Step-based progress
  if (type.includes('.step.')) {
    handleStepProgress(type, data);
    return;
  }
 
  // Sub-agent orchestration
  if (type.includes('.use_database_agent.') ||
      type.includes('.use_auth_agent.') ||
      type.includes('.use_payments_agent.')) {
    handleSubAgentOperation(type, data);
    return;
  }
 
  // Web research
  if (type.includes('.web_research.') ||
      type.includes('.web_search.')) {
    handleWebResearch(type, data);
    return;
  }
 
  // Catch-all for errors
  if (type.endsWith('.error')) {
    handleError(type, data);
    return;
  }
}

This routing keeps event handling organized as you support more operations.

File Tree Updates

Update the file tree in real-time as operations complete:

interface FileTreeNode {
  path: string;
  type: 'file' | 'directory';
  children?: FileTreeNode[];
  status?: 'loading' | 'created' | 'modified' | 'deleted' | 'error';
}
 
function updateFileTree(filePath: string, update: Partial<FileTreeNode>) {
  const tree = getFileTree();
  const node = findNodeByPath(tree, filePath);
 
  if (node) {
    // Update existing node
    Object.assign(node, update);
  } else {
    // Create new node
    const newNode: FileTreeNode = {
      path: filePath,
      type: 'file',
      ...update
    };
 
    // Insert into tree at correct location
    insertNode(tree, newNode);
  }
 
  // Re-render tree
  renderFileTree(tree);
}
 
function highlightFileInTree(filePath: string, status: string) {
  updateFileTree(filePath, { status: status as any });
 
  // Remove highlight after delay
  setTimeout(() => {
    updateFileTree(filePath, { status: undefined });
  }, 3000);
}

Visual states:

  • Loading: Spinner icon
  • Created: Checkmark (briefly), then normal file icon
  • Modified: Dot indicator, highlight background
  • Error: X icon, red background
  • Deleted: Fade out animation, then remove from tree

Terminal Output Display

Show terminal command output in a console view:

interface TerminalOutput {
  command: string;
  output: string;
  status: 'running' | 'completed' | 'error';
  timestamp: Date;
}
 
const terminalHistory: TerminalOutput[] = [];
 
function handleTerminalOperation(type: string, data: any) {
  const { command, output, error } = data;
 
  if (type.endsWith('.started')) {
    terminalHistory.push({
      command,
      output: '',
      status: 'running',
      timestamp: new Date()
    });
 
    renderTerminal();
  }
 
  if (type.endsWith('.completed')) {
    const entry = terminalHistory.find(e => e.command === command && e.status === 'running');
    if (entry) {
      entry.output = output || '';
      entry.status = error ? 'error' : 'completed';
    }
 
    renderTerminal();
  }
}

Render as a terminal-style display:

function Terminal({ history }: { history: TerminalOutput[] }) {
  return (
    <div className="bg-gray-900 text-green-400 font-mono text-sm p-4 rounded">
      {history.map((entry, i) => (
        <div key={i} className="mb-4">
          <div className="flex items-center gap-2">
            <span className="text-gray-500">$</span>
            <span>{entry.command}</span>
            {entry.status === 'running' && (
              <Spinner className="w-3 h-3 ml-2" />
            )}
          </div>
 
          {entry.output && (
            <pre className={entry.status === 'error' ? 'text-red-400' : ''}>
              {entry.output}
            </pre>
          )}
        </div>
      ))}
    </div>
  );
}

Web Research Display

Web research emits streaming chunks as the agent searches:

let researchBuffer = '';
 
function handleWebResearch(type: string, data: any) {
  if (type.endsWith('.streaming')) {
    const { chunk } = data;
 
    // Accumulate chunks
    researchBuffer += chunk;
 
    // Update research panel with streaming text
    updateResearchPanel(researchBuffer);
  }
 
  if (type.endsWith('.step.started')) {
    const { title } = data;
    showResearchStep(title, 'in_progress');
  }
 
  if (type.endsWith('.step.completed')) {
    const { title } = data;
    showResearchStep(title, 'completed');
  }
 
  if (type.endsWith('.completed')) {
    // Finalize research display
    finalizeResearchPanel(researchBuffer);
    researchBuffer = '';
  }
}

The research panel shows live text streaming like a chat interface, with step indicators showing progress through research phases.

Cancellation Handling

Let users cancel long-running operations:

let eventSource: EventSource | null = null;
 
function startAgent(projectId: string, message: string) {
  eventSource = new EventSource(
    `/api/agents/${projectId}/chat?message=${encodeURIComponent(message)}`
  );
 
  eventSource.onmessage = (e) => {
    const event = JSON.parse(e.data);
    handleEvent(event);
  };
 
  // Show cancel button
  showCancelButton();
}
 
function cancelAgent() {
  if (eventSource) {
    eventSource.close();
    eventSource = null;
  }
 
  // Send cancellation request to backend
  fetch(`/api/agents/${projectId}/cancel`, {
    method: 'POST'
  });
 
  // Update UI
  hideCancelButton();
  showNotification('Operation cancelled', 'info');
  hideLoadingIndicators();
}

The backend handles cancellation through AbortSignal, stopping tool execution and cleaning up resources.

Reconnection Handling

Handle connection drops gracefully:

let reconnectAttempts = 0;
const maxReconnectAttempts = 3;
 
eventSource.onerror = (error) => {
  console.error('SSE error:', error);
 
  if (reconnectAttempts < maxReconnectAttempts) {
    reconnectAttempts++;
 
    showNotification(
      `Connection lost. Reconnecting... (attempt ${reconnectAttempts})`,
      'warning'
    );
 
    // EventSource automatically reconnects
    // Just track attempts and show UI feedback
 
  } else {
    eventSource?.close();
    showNotification(
      'Connection lost. Please refresh the page.',
      'error',
      { duration: null } // Don't auto-dismiss
    );
  }
};
 
eventSource.onopen = () => {
  // Connection established/re-established
  reconnectAttempts = 0;
 
  if (reconnectAttempts > 0) {
    showNotification('Reconnected successfully', 'success');
  }
};

Complete Example

Here's a full implementation tying it all together:

import { useEffect, useState } from 'react';
 
interface UseAgentStreamProps {
  projectId: string;
  message: string;
}
 
function useAgentStream({ projectId, message }: UseAgentStreamProps) {
  const [events, setEvents] = useState<AgentEvent[]>([]);
  const [status, setStatus] = useState<'idle' | 'connecting' | 'streaming' | 'complete' | 'error'>('idle');
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    if (!projectId || !message) return;
 
    setStatus('connecting');
 
    const eventSource = new EventSource(
      `/api/agents/${projectId}/chat?message=${encodeURIComponent(message)}`
    );
 
    eventSource.onopen = () => {
      setStatus('streaming');
    };
 
    eventSource.onmessage = (e) => {
      const event = JSON.parse(e.data);
      setEvents((prev) => [...prev, event]);
 
      if (event.type === 'complete') {
        setStatus('complete');
        eventSource.close();
      }
    };
 
    eventSource.onerror = (err) => {
      console.error('SSE error:', err);
      setError('Connection error');
      setStatus('error');
      eventSource.close();
    };
 
    return () => {
      eventSource.close();
    };
  }, [projectId, message]);
 
  return { events, status, error };
}
 
function AgentInterface({ projectId, message }: UseAgentStreamProps) {
  const { events, status, error } = useAgentStream({ projectId, message });
 
  return (
    <div className="space-y-4">
      {/* Status indicator */}
      <div className="flex items-center gap-2">
        {status === 'streaming' && <Spinner />}
        <span>{getStatusText(status)}</span>
      </div>
 
      {/* Event list */}
      <div className="space-y-2">
        {events.map((event, i) => (
          <EventDisplay key={i} event={event} />
        ))}
      </div>
 
      {/* Error display */}
      {error && (
        <div className="bg-red-50 border border-red-200 p-4 rounded">
          <p className="text-red-800">{error}</p>
        </div>
      )}
    </div>
  );
}

What We're Skipping

Virtual scrolling for massive event lists, complex state management with Redux/Zustand, WebSocket alternatives, offline support with service workers, event replay systems. These add complexity you don't need initially.

The patterns here—EventSource for connection, event routing, UI updates, progress indicators, error handling—cover most use cases. Start simple. Add sophistication when your UX demands it.

What's Next

Your frontend now consumes events and translates them into real-time UI updates. Users see what's happening, progress is visible, and errors are immediate.

Next, we'll look at managing conversation history—how to store messages, handle context limits, and implement features like conversation branching and resume functionality. That's what turns individual interactions into persistent, resumable sessions.