Architecture
Deep dive into the Botovis ReAct agent loop, monorepo structure, and internal systems
Botovis is built as a modular system using the ReAct (Reasoning + Acting) agent pattern. This page explains the internal architecture, package structure, request lifecycle, and extension points.
Package Structure
Botovis is a monorepo with four packages, each with a single responsibility:
botovis/
├── packages/
│ ├── core/ → botovis/core (Framework-agnostic PHP)
│ ├── laravel/ → botovis/botovis-laravel (Laravel integration)
│ ├── widget/ → @botovis/widget (TypeScript Web Component)
│ └── telegram/ → botovis/botovis-telegram (Telegram bot adapter)Core Package
The core package contains all contracts (interfaces), DTOs, enums, schema models, the agent loop, and orchestration logic. It has zero framework dependencies — only PHP 8.1+ is required.
core/src/
├── Contracts/ # 8 interfaces (LlmDriver, SchemaDiscovery, ActionExecutor, etc.)
├── DTO/ # Data objects (LlmResponse, SecurityContext, Conversation, Message, AuthorizationResult)
├── Enums/ # ActionType, ColumnType, IntentType, RelationType
├── Schema/ # DatabaseSchema, TableSchema, ColumnSchema, RelationSchema
├── Tools/ # ToolInterface, ToolRegistry, ToolResult
├── Intent/ # IntentResolver, ResolvedIntent (simple mode)
├── Conversation/ # ConversationState
├── Agent/ # AgentLoop, AgentOrchestrator, AgentResponse, AgentState, AgentStep, StreamingEvent
├── Orchestrator.php
└── OrchestratorResponse.phpLaravel Package
Implements core interfaces using Laravel's Eloquent, Cache, Session, Auth, and other services:
laravel/src/
├── BotovisServiceProvider.php # DI bindings, publishes, routes, Blade directive
├── Http/
│ ├── BotovisController.php # Chat + SSE streaming endpoints
│ └── ConversationController.php # Conversation CRUD
├── Llm/
│ ├── LlmDriverFactory.php # Creates driver from config
│ ├── AnthropicDriver.php # Claude API with native tool calling
│ ├── OpenAiDriver.php # OpenAI API (+ compatible endpoints)
│ └── OllamaDriver.php # Local Ollama
├── Tools/
│ ├── BaseTool.php # Shared Eloquent helpers
│ ├── SearchRecordsTool.php # search_records
│ ├── CountRecordsTool.php # count_records
│ ├── GetSampleDataTool.php # get_sample_data
│ ├── GetColumnStatsTool.php # get_column_stats
│ ├── AggregateTool.php # aggregate
│ ├── CreateRecordTool.php # create_record
│ ├── UpdateRecordTool.php # update_record
│ └── DeleteRecordTool.php # delete_record
├── Schema/
│ └── EloquentSchemaDiscovery.php # Model reflection + DB introspection
├── Security/
│ └── BotovisAuthorizer.php # Role-based authorization
├── Conversation/
│ └── CacheConversationManager.php # Cache-based state
├── Repositories/
│ ├── EloquentConversationRepository.php # Persistent storage
│ └── SessionConversationRepository.php # Session-based storage
├── Models/
│ ├── BotovisConversation.php
│ └── BotovisMessage.php
└── Commands/
├── ModelsCommand.php # botovis:models
├── DiscoverCommand.php # botovis:discover
└── ChatCommand.php # botovis:chatWidget Package
Zero-dependency chat UI built as a Web Component with Shadow DOM:
widget/src/
├── index.ts # Custom element registration, exports
├── botovis-chat.ts # Main component (1600+ lines)
├── api.ts # REST + SSE client with CSRF handling
├── types.ts # All TypeScript interfaces
├── styles.ts # Complete CSS (Shadow DOM adopted stylesheets)
├── i18n.ts # Turkish + English translations (68 keys)
└── icons.ts # 30+ inline SVG iconsBuilt as ES module, UMD, and IIFE formats via Vite.
The ReAct Agent Loop
The core of Botovis is the ReAct (Reasoning + Acting) pattern:
User Query → Think → Act → Observe → (repeat) → Final AnswerHow It Works
- Think — The LLM analyzes the user's query and the conversation history, decides what to do
- Act — The LLM calls one or more tools (e.g.,
search_records,count_records) - Observe — The tool results are fed back to the LLM as observations
- Repeat — If the LLM needs more information, it calls more tools
- Answer — Once the LLM has enough information, it produces a natural language response
Request Lifecycle
Browser Widget Laravel AgentLoop
│ │ │ │
│ Click send │ │ │
│─────────────────────>│ │ │
│ │ POST /stream │ │
│ │────────────────────>│ │
│ │ │ Build SecurityCtx │
│ │ │ Resolve user role │
│ │ │ Get conv. history │
│ │ │────────────────────>│
│ │ │ │ Build system prompt
│ │ │ │ (schema + rules + tools)
│ │ │ │
│ │ │ │ LOOP:
│ │ │ │ ┌───────────────────────┐
│ │ │ │ │ LLM.chatWithTools() │
│ SSE: step │ │ yield step event │ │ │
│<─────────────────────│<────────────────────│<────────────────────│ │ → text? → complete │
│ │ │ │ │ → tool? → execute │
│ SSE: step (w/obs) │ │ yield step event │ │ read: run+observe │
│<─────────────────────│<────────────────────│<────────────────────│ │ write: pause+confirm│
│ │ │ │ └───────────────────────┘
│ │ │ │
│ SSE: message │ │ yield message │
│<─────────────────────│<────────────────────│<────────────────────│
│ SSE: done │ │ │
│<─────────────────────│<────────────────────│ │Confirmation Flow
When the agent detects a write operation:
Agent detects write tool (create_record, update_record, delete_record)
│
├── Adds tool_call message to state (with thought)
├── Adds [PENDING] tool_result placeholder
├── Yields SSE confirmation event to the widget
└── Pauses loop (AgentState = needs_confirmation)
User clicks Confirm
│
├── POST /stream-confirm
├── Execute the write tool
├── Replace [PENDING] with actual result
├── Resume agent loop (so LLM can summarize the result)
└── Yield message + done events
User clicks Reject
│
├── POST /reject
├── Agent receives rejection
└── Responds with cancellation messageAgent Internals
Single Step Processing
// 1. Build prompt with schema, tools, permissions, urgency warnings
$systemPrompt = $this->buildSystemPrompt($state);
// 2. Generate stopping: remove tools on last step
$toolDefs = $stepsRemaining <= 1 ? [] : $this->tools->toFunctionDefinitions();
// 3. Call LLM with native tool calling
$response = $this->llm->chatWithTools($systemPrompt, $messages, $toolDefs);
// 4. Handle response
if ($response->isText()) → complete with answer
if ($response->isToolCall()) → handleToolCalls()Parallel Tool Calls
When the LLM returns multiple tool calls in a single response:
// 1. Add ALL tool_call messages first (API requires balanced pairs)
foreach ($toolCalls as $tc) {
$state->addToolCallMessage($tc['id'], $tc['name'], $tc['params']);
}
// 2. Process each: read → execute, write → [PENDING]
foreach ($toolCalls as $tc) {
if ($tool->requiresConfirmation()) {
$state->addToolResultMessage($tc['id'], '[PENDING]...');
} else {
$result = $this->tools->execute($tc['name'], $tc['params']);
$state->addToolResultMessage($tc['id'], $result->toObservation());
}
}
// 3. All results count as ONE step
$step = AgentStep::action($stepNum, $thought, $toolNames, $observations);Generate Stopping
Prevents the "max steps reached with no answer" scenario:
- Urgency warnings — At ≤3 steps remaining, the system prompt includes a WARNING telling the agent to wrap up
- Critical message — At ≤1 step remaining, a CRITICAL message forces the agent to answer immediately
- Tool removal — On the very last step, tools are removed from the API call entirely, forcing a text-only response
LLM Driver Architecture
All drivers implement LlmDriverInterface:
interface LlmDriverInterface
{
public function chat(string $systemPrompt, array $messages): string;
public function chatWithTools(string $systemPrompt, array $messages, array $tools): LlmResponse;
public function stream(string $systemPrompt, array $messages, callable $onToken): string;
public function name(): string;
}Normalized Message Format
Internally, Botovis uses a provider-independent message format:
// Tool call (from LLM)
['role' => 'assistant', 'content' => 'thought...', 'tool_call' => [
'id' => 'call_123',
'name' => 'count_records',
'params' => ['table' => 'products'],
]]
// Tool result (observation)
['role' => 'tool_result', 'tool_call_id' => 'call_123', 'content' => 'Count: 247']Each driver's convertMessages() method translates this to the API-specific format:
- Anthropic — Content blocks with
tool_use/tool_resulttypes, merges consecutive same-role messages - OpenAI —
tool_callsarray on assistant messages, separatetoolrole messages - Ollama — Similar to OpenAI with synthetic tool call IDs
Schema Discovery
EloquentSchemaDiscovery builds the database schema by combining:
- Eloquent reflection — Reads fillable/guarded, casts, relationships from model classes
- Database introspection — Queries column types, nullable, defaults, max length, enum values
- Convention-based discovery — Detects enum values from static methods (
statusOptions(),statusLabels(), etc.) - Label generation — Converts model names to display labels (e.g.,
ProductCategory→ "Product Categories")
The resulting DatabaseSchema is:
- Sent to the LLM as system prompt context
- Filtered by user permissions before display
- Used by tools to validate table names and columns
Service Container Bindings
The BotovisServiceProvider registers these singletons:
| Interface | Default Implementation |
|---|---|
SchemaDiscoveryInterface | EloquentSchemaDiscovery |
LlmDriverInterface | LlmDriverFactory::make() |
ActionExecutorInterface | EloquentActionExecutor |
ConversationManagerInterface | CacheConversationManager |
ConversationRepositoryInterface | EloquentConversationRepository |
BotovisAuthorizer | BotovisAuthorizer |
ToolRegistry | Configured with 8 built-in tools |
Orchestrator | Simple mode orchestrator |
AgentOrchestrator | Agent mode orchestrator |
Extension Points
Botovis is designed to be extensible. You can replace almost any component:
Custom LLM Driver
use Botovis\Core\Contracts\LlmDriverInterface;
class MyCustomDriver implements LlmDriverInterface
{
// Implement all methods...
}
// In a service provider:
$this->app->singleton(LlmDriverInterface::class, fn() => new MyCustomDriver());Custom Tools
$this->app->afterResolving(ToolRegistry::class, function ($registry) {
$registry->register(new MyCustomTool());
});See the Tools page for details on creating custom tools.
Custom Authorizer
use Botovis\Core\Contracts\AuthorizerInterface;
$this->app->singleton(AuthorizerInterface::class, fn() => new MyCustomAuthorizer());Custom Conversation Storage
use Botovis\Core\Contracts\ConversationRepositoryInterface;
$this->app->singleton(ConversationRepositoryInterface::class, fn() => new RedisConversationRepository());Asset Publishing
| Tag | Files | Destination |
|---|---|---|
botovis-config | config/botovis.php | config/ |
botovis-assets | Widget JS bundle | public/vendor/botovis/ |
botovis-views | Blade templates | resources/views/vendor/botovis/ |
botovis-migrations | Migration files | database/migrations/ |