The Sprout backend is an Express 5 + TypeScript API that orchestrates AI agents, manages the learning graph database, and streams real-time progress via Server-Sent Events.
Core Structure
Server Entry Point
The main server (src/index.ts) sets up routes and auto-seeds the default user:
import express from "express" ;
import cors from "cors" ;
import { db } from "./db" ;
import { users } from "./db/schema" ;
const DEFAULT_USER_ID = "00000000-0000-0000-0000-000000000000" ;
async function ensureDefaultUser () {
const existing = await db . select (). from ( users )
. where ( eq ( users . id , DEFAULT_USER_ID ));
if ( ! existing . length ) {
await db . insert ( users ). values ({
id: DEFAULT_USER_ID ,
email: "default@sprout.local" ,
title: "Default Learner" ,
});
}
}
const app = express ();
app . use ( cors ());
app . use ( express . json ());
// Route mounting
app . use ( "/api/branches" , branchesRouter );
app . use ( "/api/nodes" , nodesRouter );
app . use ( "/api/assessments" , assessmentsRouter );
app . use ( "/api/progress" , progressRouter );
app . use ( "/api/chat" , chatRouter );
app . use ( "/api/agents" , agentsRouter );
ensureDefaultUser (). then (() => {
app . listen ( 8000 , () => {
console . log ( "Sprout backend running on http://localhost:8000" );
});
});
Route Structure
1. Agents Routes (/api/agents/*)
The agents router handles all AI agent orchestration:
Topic Agent
Concept Refinement
Review Agent
// POST /api/agents/topics/:topicNodeId/run
// Two-phase pipeline: Topic Agent → Subconcept Bootstrap (parallel)
router . post ( "/topics/:topicNodeId/run" , async ( req , res , next ) => {
const { userId , small } = req . body ;
const topicNode = await db . select (). from ( nodes )
. where ( eq ( nodes . id , req . params . topicNodeId ));
const sse = initSSE ( res );
sse . registerAgent ();
// Phase 1: Generate concepts
const topicResult = await runTopicAgent ({
userId , topicNode , sse , small: !! small
});
// Phase 2: Bootstrap subconcepts (max 3 concurrent)
const limit = pLimit ( 3 );
const bootstrapPromises = topicResult . concepts . map (( concept ) => {
sse . registerAgent ();
return limit ( async () => {
await runSubconceptBootstrapAgent ({
userId , conceptNode: concept . node ,
topicTitle: topicNode . title ,
documentContext: concept . documentContext ,
sse , small: !! small ,
generateQuestions: true
});
sse . resolveAgent ();
});
});
await Promise . all ( bootstrapPromises );
sse . send ( 'agent_done' , { agent: 'topic' });
sse . resolveAgent ();
});
2. Chat Routes (/api/chat/*)
Handles tutor chat sessions:
// POST /api/chat/sessions/:sessionId/tutor
router . post ( "/sessions/:sessionId/tutor" , async ( req , res ) => {
const { message , nodeId } = req . body ;
const session = await db . select (). from ( chatSessions )
. where ( eq ( chatSessions . id , req . params . sessionId ));
// Load full conversation history
const messages = await db . select (). from ( chatMessages )
. where ( eq ( chatMessages . sessionId , session . id ))
. orderBy ( chatMessages . createdAt );
// Run tutor agent with context
const response = await runTutorAgent ({
userId: session . userId ,
nodeId ,
sessionId: session . id ,
conversationHistory: messages ,
studentMessage: message
});
// Persist messages
await db . insert ( chatMessages ). values ([
{ sessionId: session . id , role: 'user' , content: message },
{ sessionId: session . id , role: 'assistant' , content: response . content }
]);
return res . json ( response );
});
3. Document Routes (/api/nodes/:nodeId/documents)
Handles S3 uploads for topic documents:
import { S3Client , PutObjectCommand } from '@aws-sdk/client-s3' ;
import multer from 'multer' ;
import pdfParse from 'pdf-parse' ;
const upload = multer ({ storage: multer . memoryStorage () });
router . post (
"/nodes/:nodeId/documents" ,
upload . array ( 'files' ),
async ( req , res ) => {
const files = req . files as Express . Multer . File [];
const nodeId = req . params . nodeId ;
for ( const file of files ) {
const s3Key = `documents/ ${ nodeId } / ${ uuid () } - ${ file . originalname } ` ;
// Upload to S3
await s3Client . send ( new PutObjectCommand ({
Bucket: process . env . AWS_S3_BUCKET ,
Key: s3Key ,
Body: file . buffer ,
ContentType: file . mimetype
}));
// Extract text from PDF
let extractedText = null ;
if ( file . mimetype === 'application/pdf' ) {
const pdf = await pdfParse ( file . buffer );
extractedText = pdf . text ;
}
// Save to database
await db . insert ( topicDocuments ). values ({
id: uuid (),
nodeId ,
originalFilename: file . originalname ,
s3Key ,
mimeType: file . mimetype ,
fileSizeBytes: file . size ,
extractedText ,
extractionStatus: 'completed'
});
}
return res . json ({ uploaded: files . length });
}
);
Agent Loop Architecture
All agents use a shared loop (src/agents/agent-loop.ts) for tool calling:
export async function agentLoop ({
messages ,
tools ,
maxIterations = 15 ,
onThinking ,
onToolCall ,
onToolResult
} : AgentLoopOptions ) : Promise < AgentLoopResult > {
const conversationHistory = [ ... messages ];
const allToolCalls : ToolCall [] = [];
let finalText = '' ;
let iterations = 0 ;
while ( iterations < maxIterations ) {
iterations ++ ;
const response = await anthropic . messages . create ({
model: CLAUDE_MODEL ,
max_tokens: 8000 ,
messages: conversationHistory ,
tools
});
// Extract reasoning text
const textBlocks = response . content . filter ( b => b . type === 'text' );
if ( textBlocks . length && onThinking ) {
const thinkingText = textBlocks . map ( b => b . text ). join ( ' \n ' );
onThinking ( thinkingText );
finalText = thinkingText ;
}
// Process tool calls
const toolUseBlocks = response . content . filter ( b => b . type === 'tool_use' );
if ( ! toolUseBlocks . length ) {
break ; // Claude stopped calling tools
}
const toolResults = [];
for ( const toolBlock of toolUseBlocks ) {
if ( onToolCall ) onToolCall ( toolBlock . name , toolBlock . input );
const tool = tools . find ( t => t . name === toolBlock . name );
const result = await tool . execute ( toolBlock . input );
allToolCalls . push ({ name: toolBlock . name , input: toolBlock . input });
if ( onToolResult ) onToolResult ( toolBlock . name , result );
toolResults . push ({
type: 'tool_result' ,
tool_use_id: toolBlock . id ,
content: typeof result === 'string' ? result : JSON . stringify ( result )
});
}
// Feed results back to Claude
conversationHistory . push (
{ role: 'assistant' , content: response . content },
{ role: 'user' , content: toolResults }
);
if ( response . stop_reason === 'end_turn' ) break ;
}
return { finalText , toolCalls: allToolCalls , iterations };
}
The agent loop includes retry with exponential backoff for 429 rate limits (2s, 4s, 8s delays).
SSE Streaming Implementation
The SSE writer (src/utils/sse.ts) handles real-time event delivery:
export interface SSEWriter {
send ( event : string , data : object ) : void ;
close () : void ;
registerAgent () : void ;
resolveAgent () : void ;
}
const DRAIN_INTERVAL_MS = 50 ;
export function initSSE ( res : Response ) : SSEWriter {
res . setHeader ( 'Content-Type' , 'text/event-stream' );
res . setHeader ( 'Cache-Control' , 'no-cache' );
res . setHeader ( 'Connection' , 'keep-alive' );
res . setHeader ( 'X-Accel-Buffering' , 'no' );
res . flushHeaders ();
const queue : QueueItem [] = [];
let agentCount = 0 ;
let closed = false ;
function drain () {
while ( queue . length > 0 ) {
const item = queue . shift () ! ;
const payload = `event: ${ item . event } \n data: ${ JSON . stringify ( item . data ) } \n\n ` ;
res . write ( payload );
}
if ( typeof res . flush === 'function' ) res . flush ();
}
const interval = setInterval ( drain , DRAIN_INTERVAL_MS );
return {
send ( event , data ) {
if ( closed ) return ;
queue . push ({ event , data });
},
close () {
if ( closed ) return ;
closed = true ;
clearInterval ( interval );
drain ();
res . end ();
},
registerAgent () {
agentCount ++ ;
},
resolveAgent () {
agentCount = Math . max ( 0 , agentCount - 1 );
if ( agentCount === 0 ) {
setTimeout (() => this . close (), DRAIN_INTERVAL_MS * 2 );
}
}
};
}
SSE Event Types
Agent Events
Graph Events
Tool Events
Diagnostic Events
agent_start: Agent has started
agent_reasoning: Claude’s reasoning text
agent_done: Agent completed successfully
agent_error: Agent failed with error
node_created: New node persisted to DB
edge_created: New edge persisted to DB
node_removed: Node deleted
edge_removed: Edge deleted
tool_call: Tool invocation
tool_result: Tool result (truncated to 200 chars)
diagnostic_status: Assessment readiness
json_response: Agent returned JSON data
Each agent has access to specific tools that persist data to the database:
Topic Agent Tools
Concept Refinement Tools
Tutor Agent Tools
{
name : 'extract_all_concept_contexts' ,
description : 'Batch-extract relevant document sections for all concepts' ,
input_schema : {
concepts : [{ title , desc , relevantKeywords }]
}
},
{
name : 'save_concept' ,
description : 'Create a concept node in the DB' ,
input_schema : { title , desc , documentContext }
},
{
name : 'save_concept_edge' ,
description : 'Create a dependency edge between concepts' ,
input_schema : { sourceTitle , targetTitle }
}
Tools persist data directly — their return values are used only for confirmation. This ensures all graph mutations are immediately visible to other agents and streamed to the frontend.
Error Handling
Global error handler catches all route errors:
src/middleware/error-handler.ts
export function errorHandler (
err : Error ,
req : Request ,
res : Response ,
next : NextFunction
) {
console . error ( 'Error:' , err );
if ( res . headersSent ) {
return next ( err );
}
res . status ( 500 ). json ({
error: err . message || 'Internal server error'
});
}
Next Steps
Agent Deep Dive Learn about each of the seven AI agents
Database Schema Explore the SQLite data model