Designing Agent-Native Systems: A Deep Dive into StudyPal
The transition from traditional LLM applications to agent-native systems represents a fundamental shift: from static prompt-response templates to autonomous, goal-driven execution loops. StudyPal is our case study for what that looks like in practice — not just a smarter chatbot, but a full personalized tutoring platform where AI agents plan, execute, and observe across multi-step pipelines.
This article takes you under the hood: the real interfaces, the actual module structure, and the design decisions that make the system composable and observable at scale.
See It in Action
The Foundation: HKU DeepTutor's Two-Layer Architecture
StudyPal is built on the open-source HKU DeepTutor framework. Rather than flooding the LLM with every available tool upfront, it separates concerns into two clean layers:
[Web UI / CLI / SDK]
│
▼
[ChatOrchestrator] ← runtime/orchestrator.py
│ routes via context.active_capability
├──► [CapabilityRegistry]
│ │
│ [BaseCapability.run(UnifiedContext, StreamBus)]
│
└──► [ToolRegistry] ← shared across all modes
│
[BaseTool.execute(**kwargs) → ToolResult]
Level 1 (ToolRegistry) — Lightweight, stateless, single-function tools the LLM calls on-demand during any conversation turn.
Level 2 (CapabilityRegistry) — Multi-step, stateful agent pipelines that take control of the full execution loop when the user activates a deep mode.
Level 1: The Real Tool Protocol
Every tool — built-in or contributed — implements BaseTool from
deeptutor/core/tool_protocol.py. The real interface uses typed dataclasses for schema
generation, not simple abstract properties:
# deeptutor/core/tool_protocol.py
class BaseTool(ABC):
@abstractmethod
def get_definition(self) -> ToolDefinition:
"""Return the tool's metadata & parameter schema."""
...
@abstractmethod
async def execute(self, **kwargs: Any) -> ToolResult:
"""Run the tool and return a typed result."""
...
@property
def name(self) -> str:
return self.get_definition().name
ToolDefinition carries the OpenAI function-calling schema (built from ToolParameter
dataclasses), while ToolResult is a standardised return type — not a raw string — carrying
content, sources, metadata, and a success flag:
@dataclass
class ToolResult:
content: str = ""
sources: list[dict[str, Any]] = field(
default_factory=list
)
metadata: dict[str, Any] = field(
default_factory=dict
)
success: bool = True
Adding a new tool means implementing one class. The ToolRegistry handles discovery, OpenAI
schema generation, and aliased execution — one call surface for all consumers.
The 8 real built-in tools:
| Tool | Purpose |
|---|---|
rag | Vector KB retrieval via LlamaIndex |
web_search | Live web search |
code_execution | Sandboxed Python runner |
reason | Chain-of-thought reasoning |
brainstorm | Idea generation and expansion |
paper_search | arXiv / academic paper retrieval |
geogebra_analysis | Interactive math graph analysis |
tex_chunker | LaTeX paper parsing and chunking |
Level 2: The Real Capability Protocol
When a task requires planning, multiple tool calls, validation, and synthesis, a stateless
tool is not enough. That is where Capabilities come in. Each one declares a
CapabilityManifest at the class level and implements a single run method:
# deeptutor/core/capability_protocol.py
class BaseCapability(ABC):
manifest: CapabilityManifest # declared on subclass
@abstractmethod
async def run(
self,
context: UnifiedContext,
stream: StreamBus,
) -> None: ...
@property
def name(self) -> str:
return self.manifest.name
The manifest drives the CLI (via cli_aliases), tool selection (via tools_used), and the
observable stage lifecycle (via stages). Here is the real DeepSolveCapability:
# deeptutor/capabilities/deep_solve.py
class DeepSolveCapability(BaseCapability):
manifest = CapabilityManifest(
name="deep_solve",
description="Multi-agent solving (Plan → ReAct → Write).",
stages=["planning", "reasoning", "writing"],
tools_used=[
"rag", "web_search",
"code_execution", "reason",
],
cli_aliases=["solve"],
)
async def run(
self, context: UnifiedContext, stream: StreamBus
) -> None:
solver = MainSolver(...)
await solver.ainit()
# _trace_bridge routes solver events to stream bus
result = await solver.solve(
question=context.user_message,
)
await stream.result(
{"response": result["final_answer"]},
source=self.name,
)
All five built-in capabilities and their pipeline stages:
| Capability | CLI Alias | Pipeline Stages |
|---|---|---|
chat | chat | thinking → acting → observing → responding |
deep_solve | solve | planning → reasoning → writing |
deep_question | quiz | ideation → generation |
deep_research | research | rephrasing → decomposing → researching → reporting |
math_animator | animate | concept_analysis → design → code_generation → render |
The StreamBus: Making Execution Observable
StreamBus (deeptutor/core/stream_bus.py) is the async fan-out event bus that connects
producers (capabilities and tools) to consumers (CLI renderer, WebSocket pusher, SDK). It is
what makes any capability immediately usable across all surfaces without modification.
Every emit call is typed — no raw strings:
# Inside any capability or tool
await stream.thinking(
"Analysing the problem...", source=self.name
)
await stream.tool_call(
"rag", args={"query": q}, source=self.name
)
await stream.tool_result(
"rag", result=text, source=self.name
)
await stream.content(final_answer, source=self.name)
The stage() context manager automatically emits STAGE_START and STAGE_END events,
giving consumers a structured view of where execution is in the pipeline:
async with stream.stage("planning", source=self.name):
plan = await self._plan(context)
await stream.thinking(plan, source=self.name)
# → STAGE_START and STAGE_END emitted automatically
This is what powers live progress indicators in the UI — and what lets the CLI and WebSocket consumers share the exact same capability code.
The ChatOrchestrator: One Entry Point for Everything
ChatOrchestrator (deeptutor/runtime/orchestrator.py) is the single routing layer. CLI,
WebSocket, and SDK all call handle(context) and receive the same StreamEvent stream:
# deeptutor/runtime/orchestrator.py
class ChatOrchestrator:
async def handle(
self, context: UnifiedContext
) -> AsyncIterator[StreamEvent]:
cap_name = context.active_capability or "chat"
capability = self._cap_registry.get(cap_name)
bus = StreamBus()
asyncio.create_task(
capability.run(context, bus)
)
async for event in bus.subscribe():
yield event # same contract for all consumers
Set context.active_capability to None and you get plain chat. Set it to "deep_solve"
and the same call routes to a three-stage multi-agent solver. The consumer never changes —
only the capability does.
Beyond the Architecture: The Workspace Tools Suite
This is where the story gets personal. On top of the DeepTutor foundation, I built a suite of nine interactive learning tools — each deeply integrated with StudyPal's persistent memory and your document knowledge base. Not demos: production-ready features designed for how students actually learn.
🎙️ Voice Assistant
Speak directly to your TutorBot in real-time. Built on Vocal Bridge, it features an immersive full-screen audio session UI, live streaming text transcript, low-latency audio responses, and fully customizable voice agent profiles. Every voice session shares the same conversation memory as your text sessions — no context is lost when switching modes.
📅 Adaptive Study Planner
An interactive learning calendar driven by CopilotKit sidebar agents. It doesn't just let you plan — it plans with you. Based on your topics and deadlines, it automatically schedules focused study blocks. When you miss a session or a deadline shifts, it dynamically re-balances your remaining calendar. Reactive planning, not static scheduling.
⏱️ Focus Mode & Ambient Sounds
Distraction-free deep work sessions with a Pomodoro countdown timer (Focus, Short Break, and Long Break presets), integrated with a minimal tasks panel. Pair it with ambient soundscapes — Lofi Study, Rain, Forest/Birds, or White Noise — with per-track volume control. A small feature with an outsized impact on actually sitting down and doing the work.
🎨 Interactive Whiteboard
An infinite digital canvas powered by an embedded draw.io iframe, controlled via the
postMessage API. The AI sidebar panel lets you toggle RAG and web search to auto-generate
or refine diagrams from a natural language description. Supports graphing interactive
GeoGebra curves directly on the canvas — useful for anything from system architecture
diagrams to calculus visualisations.
🕸️ Semantic Mindmap
Upload notes or paste text and watch them transform into an interactive node graph. Driven by CopilotKit, you can query the AI tutor to expand a node, restructure the hierarchy, or trace relationships between concepts — all without leaving the visual canvas. The mindmap stays in sync with your actual uploaded documents, not generic topic clusters.
🎧 Podcast Generator (Audio Overviews)
Turn dense readings, notebooks, or custom topics into conversational audio. The generator produces dual-host discussion scripts between AI speakers Sarah and Alex, then synthesizes them to high-quality audio files using a Kokoro TTS pipeline. Useful for commutes, review sessions, or simply processing complex material in a different modality.
📊 Study Decks (Presenter)
Convert research papers, notes, or learning topics into clean, structured PowerPoint
presentations (.pptx). The pipeline generates outline slides, fills in bulleted key
concepts, and packages the deck with StudyPal design templates — ready to download or
present. Particularly useful when you need to explain a topic to someone else.
🃏 Flashcards
Generate self-study card decks from any chat session. Supports Q/A and Cloze deletion formats with built-in LaTeX math and code rendering. Flip cards with the spacebar, self-grade with Again or Knew it, and let spaced-repetition logic surface the cards that need the most work. Decks are grounded in your actual documents — not generic definitions.
📝 Exam Simulator
Test your real readiness under pressure. The simulator generates a mix of Multiple Choice, Short Answer, and Long Answer questions grounded in the specific documents or topics you choose. It enforces a strict countdown timer with auto-submission — no pausing, no peeking. After submission, a comprehensive AI grading pass uses customised rubrics to score your answers and return detailed feedback, not just a number.
Why the Separation of Concerns Matters
The BaseTool / BaseCapability / StreamBus triad is what makes StudyPal composable and
extensible without becoming a maintenance nightmare. Adding a new capability means
implementing one BaseCapability subclass and declaring a CapabilityManifest. Adding a new
tool means implementing one BaseTool subclass with a get_definition().
Every workspace tool above — regardless of its complexity — either maps to a BaseCapability
pipeline using the StreamBus for observability, or uses the ToolRegistry to inject RAG,
web search, or code execution into its session context. The same abstractions, composed
differently.
# Deep problem solving
deeptutor solve "Prove that √2 is irrational"
# Generate a quiz grounded in your knowledge base
deeptutor quiz --kb my_notes "Chapter 4: Thermodynamics"
# Launch a deep research report
deeptutor research "Attention mechanisms in modern LLMs"
Agent-native software is about managing complexity and state at scale. By keeping the interface clean, making execution observable through a typed event bus, and strictly separating stateless tools from stateful pipelines, we build systems that don't get lost in infinite loops — and students who actually learn.