Crystal-Agents — System Internal Design & Architecture
Version: v0.3.0
Date: 2026-06-22
Purpose: Smallpreneur agent stack for structured business automation on a single VPS.
1. High-Level Architecture
Crystal-Agents is a workflow-driven multi-agent orchestration system. It accepts tasks through four entry points (CLI, web dashboard, Microsoft Teams webhook, Telegram bot), routes them to defined workflows, executes steps via LLM-backed agents with tool access, gates risky actions behind human approval, and surfaces everything through a local dashboard.
┌──────────────┬──────────────┬──────────────┬──────────────┐
│ CLI │ Dashboard │ Teams │ Telegram │
│ (argparse) │ (HTTP/HTML) │ (Webhook) │ (Polling) │
└──────┬───────┴──────┬───────┴──────┬───────┴──────┬───────┘
│ │ │ │
└──────────────┴──────────────┴──────────────┘
│
┌──────▼──────┐
│ Runtime │ build_runtime() wires everything
│ Bundle │ AppConfig + PolicyConfig + Notifier
└──────┬──────┘
│
┌──────▼──────┐
│ CEO Router │ Keyword match → fallback to LLM
│ (classifier)│ classification → workflow name
└──────┬──────┘
│
┌──────▼──────┐
│ Orchestrator│ route() → canonical workflow name
│ │ run() → delegates to WorkflowRunner
└──────┬──────┘
│
┌──────▼──────┐
│ Workflow │ Iterates steps: agent + prompt + tool
│ Runner │ Checks policy.json before tool calls
└──────┬──────┘
│
┌───────────┼───────────┐
│ │ │
┌─────▼─────┐ ┌──▼──┐ ┌─────▼─────┐
│ LLM Call │ │Tool │ │ Approval │
│ (DeepSeek)│ │Exec │ │ Store │
└───────────┘ └─────┘ └───────────┘Key Design Principle
Every workflow is a sequence of steps. Each step is a (agent, prompt_template, optional_tool) triple. The runner feeds the prompt to the agent’s LLM, captures the response, optionally invokes a tool, and feeds output into the next step. There are two execution models:
| Model | Used By | Mechanism |
|---|---|---|
Standard (build()) |
16/18 workflows | WorkflowDefinition with structured WorkflowStep list |
Direct (run_direct()) |
chat, audit_fix_loop |
Custom Python code accessing runtime directly |
The runner auto-detects which model to use via hasattr(workflow_obj, "run_direct").
2. Layer-by-Layer Walkthrough
2.1 Configuration Layer (config/)
AppConfig (config/app_config.py) is a @dataclass(slots=True) tree. Load path:
config/config.json → env var overrides → dataclass defaults
(base) (secrets) (fallback)Env var naming: CRYSTAL_{SECTION}_{KEY} (e.g., CRYSTAL_TEAMS_WEBHOOK_URL, CRYSTAL_B2_APPLICATION_KEY). A false string value in config JSON explicitly disables a channel even if credentials exist.
PolicyConfig (config/policy.json) defines:
- Per-agent tool allowlists (
agent_tools) - Per-tool approval requirements (
tool_approval_required) - Default policy for unknown agents/tools
2.2 Entry Points
| Entry | Location | Mechanism |
|---|---|---|
| CLI | orchestrator/cli.py |
argparse with subcommands: run, dashboard, scheduler, reports, telegram, integrated |
| Dashboard | dashboard/app.py |
ThreadingHTTPServer + BaseHTTPRequestHandler, zero-framework HTML rendering |
| Teams | dashboard/routes/teams.py |
Incoming webhook, HMAC-SHA256 auth, async workflow dispatch |
| Telegram | orchestrator/telegram_bot.py |
Long-polling bot (disabled by config in production) |
All converge on build_runtime() → Orchestrator.run().
2.3 Orchestrator Layer (orchestrator/)
orchestrator/
├── cli.py # CLI entry point
├── orchestrator.py # Core Orchestrator: route() + run()
├── runner.py # WorkflowRunner: step execution loop
├── ceo_router.py # LLM-based natural language → workflow classifier
├── runtime.py # build_runtime(): dependency injection / wiring
├── types.py # TaskRequest, RunRecord dataclasses
├── approvals.py # ApprovalStore (JSONL-based pending/approved/denied)
├── policy.py # PolicyConfig loader from policy.json
├── llm_providers.py # ModelProviderClient (OpenAI-compatible API)
├── context_builder.py # Builds LLM context from memory files
├── memory_loader.py # Loads company/product knowledge from memory/
├── agent_generation.py # Generates agent definitions from memory
├── agent_loop.py # Agent execution loop (single-turn)
├── audits.py # Audit orchestration
├── geo.py # GEO scan orchestration
├── publishes.py # Content publishing orchestration
└── telegram_bot.py # Telegram polling botRouting Flow
-
CEORouter.route_request(prompt)— first checks keyword patterns (~20 if-elif blocks for slash commands and known phrases), then falls back to LLM classification if no keyword matches. Returns{"action": "workflow|scheduler_list|reports|scheduler_run", "workflow": "...", "prompt": "..."}. -
Orchestrator.route(task)— maps the task type string to a canonical workflow name. Handles aliases (feature-spec→feature_spec,content→content_drafting). Falls through to the raw task type if no mapping exists. -
Orchestrator.run(task)— creates a run directory underruns/<run_id>/, writesmanifest.json, delegates toWorkflowRunner.run().
Step Execution (WorkflowRunner)
For each step in the workflow definition:
- Resolve agent by name from
AgentRegistry - Build prompt from template + previous step outputs
- Call LLM via
ModelProviderClient - Parse response for tool calls (
parse_edit_instructionsforrepo_writer, or tool name extraction) - If tool call found: check
PolicyConfig— ifrequires_approval, block and wait (up to 5 min pollingApprovalStore) - Execute tool via
ToolExecutor - Feed tool output into next step
- On completion: write
result.md, notify viaMultiChannelNotifier
2.4 Agent Layer (agents/)
Each agent is a named role with a system prompt template:
| Agent | File | Role |
|---|---|---|
| CEO | ceo.py |
Routing decisions, high-level coordination |
| Developer | developer.py |
Code reading, writing, reviewing |
| Tester | tester.py |
Test generation, QA |
| DevOps | devops.py |
Infrastructure, deployment |
| Research | research.py |
Web research, data gathering |
| Content (LinkedIn) | content/linkedin.py |
LinkedIn post drafting |
| Content (Twitter) | content/twitter.py |
Tweet drafting |
| Content (Reddit) | content/reddit.py |
Reddit post drafting |
| Content (Newsletter) | content/newsletter.py |
Newsletter drafting |
| Content (Docs) | content/docs_writer.py |
Documentation writing |
| Ads Manager | ads_manager.py |
Ad campaign management |
| Analytics | analytics.py |
Data analysis |
| GEO | geo.py |
Generative Engine Optimization |
| Reviewer | reviewer.py |
Content/code review |
| Sales Assistant | sales_assistant.py |
Lead follow-up, proposals |
| SEO | seo.py |
SEO audit and recommendations |
All agents inherit from BaseAgent which provides parse_edit_instructions() for extracting file edits from LLM responses.
2.5 Workflow Layer (workflows/)
18 workflow definitions, each a WorkflowDefinition with a sequence of WorkflowStep objects:
| Workflow | Canonical Name | Steps | Typical Use |
|---|---|---|---|
| Content Drafting | content_drafting |
research → content → review | Blog posts, social media |
| Content Review | content_review |
review → content | Review existing content |
| Content Publish | content_publish |
content → publisher | Generate + publish |
| Feature Spec | feature_spec |
ceo → developer → tester | Software feature planning |
| Git Workflow | git_workflow |
developer → reviewer | Branch, code, PR |
| Bug Triage | bug_triage |
developer → tester | Bug analysis |
| Campaign Creation | campaign_creation |
research → ads_manager | Marketing campaigns |
| Lead Follow-up | lead_followup |
sales_assistant | Sales automation |
| Proposal Generation | proposal_generation |
sales_assistant → reviewer | Business proposals |
| Release Preparation | release_preparation |
devops → reviewer | Release notes, changelogs |
| Customer Reporting | customer_reporting |
analytics | Client reports |
| Business Onboarding | business_onboarding |
research → ceo | New client setup |
| Codebase Onboarding | codebase_onboarding |
developer | New repo analysis |
| SEO Audit | seo_audit |
seo → reviewer | Website SEO analysis |
| Code Audit | code_audit |
developer → reviewer | Code quality review |
| Audit Fix Loop | audit_fix_loop |
run_direct() | Audit → fix → PR cycle |
| GEO Scan | geo_scan |
geo → reviewer | Generative engine optimization scan |
| Chat | chat |
run_direct() | General conversational agent |
2.6 Tool Layer (tools/)
Tools are concrete capabilities. Each is registered in ToolRegistry and gated by policy.json:
| Tool | File | Requires Approval | Purpose |
|---|---|---|---|
repo_reader |
repo_reader.py |
No | Read files from git workspace |
repo_writer |
repo_writer.py |
Yes | Write/edit files in git workspace |
web_search |
web_search.py |
No | DuckDuckGo search |
git_ops |
git_ops.py |
Yes | Git branch, commit, push, PR |
publisher |
publisher.py |
Yes | Publish content to platforms |
email_sender |
email_sender.py |
Yes | Send emails via SMTP |
website_scanner |
website_scanner.py |
No | Crawl/scan websites |
site_auditor |
site_auditor.py |
No | SEO site audit |
issue_tracker |
issue_tracker.py |
No | Track issues/action items |
b2_client |
b2_client.py |
No | Backblaze B2 cloud storage |
s3_assets |
s3_assets.py |
No | S3-compatible asset storage |
Approval flow: When a tool marked requires_approval: true is invoked, the ApprovalStore creates a pending entry. The workflow pauses (polling every few seconds, up to a 5-minute timeout). An operator approves or denies via the dashboard /approvals page or Teams /approve <key> command.
2.7 Scheduler Layer (scheduler/)
scheduler/
├── cron.py # CronExpression parser (5-field cron)
├── service.py # SchedulerService: poll loop, job execution
├── history.py # SchedulerHistoryStore (JSONL)
└── models.py # SchedulerEvent, SchedulerJobPlanThe scheduler runs on a configurable poll interval (default 60s). Each tick:
- Parse current time
- Find jobs whose cron expression matches
- Execute the allowlisted bash script as a subprocess
- Jobs marked
requires_approval: truepause until approved - Log results to
logs/scheduler_history.jsonl
The integrated CLI command runs dashboard + scheduler loop + telegram bot as child subprocesses with a supervisor that monitors for failures.
2.8 Notification Layer (notifications/)
notifications/
├── multi.py # MultiChannelNotifier: fans out to all enabled channels
├── teams.py # TeamsNotifier: Adaptive Cards to MS Teams webhook
└── telegram.py # TelegramNotifier: messages to Telegram chatThe MultiChannelNotifier checks each channel’s enabled() method. If enabled: false in config, the channel is silently skipped. Teams notifications use Microsoft Adaptive Card format (v1.4) with color-coded titles (error=red, success=green, neutral=blue) and structured FactSet sections.
2.9 Dashboard Layer (dashboard/)
A zero-framework HTTP server. DashboardHandler extends BaseHTTPRequestHandler:
- Auth: HTTP Basic Authentication on every request (except Teams webhook endpoint). No sessions, no CSRF tokens.
- Routing:
do_GET/do_POSTdispatch to route handlers via lazy imports (to avoid circular dependencies). - Rendering: All HTML is generated inline via
_page(title, body)which wraps content in a dark-themed CSS grid layout with sidebar navigation. - State:
DashboardStateholds references toAppConfig,ApprovalStore,SchedulerService,ReportGenerator,CEORouter, andActionStore.
Route handlers live in dashboard/routes/:
home.py— Main page with run form + recent runs tableteams.py— Teams webhook handler (no auth, HMAC verification)actions.py— Action feed viewapprovals.py— Approval queue with approve/deny buttonsruns.py— Run detail viewscheduler.py— Scheduler status + manual triggerreports.py— Daily/weekly report viewsaudits.py— Audit resultsissues.py— Issue trackerpublishes.py— Publish historygeo.py— GEO scan resultsdocs.py— Document center (B2 integration, work-in-progress)
2.10 Storage Pattern
The system uses JSONL files (one JSON object per line) as its primary storage. At least 8 stores follow this identical pattern:
| Store | File | Purpose |
|---|---|---|
ApprovalStore |
logs/approvals.jsonl |
Pending/approved/denied actions |
ActionStore |
logs/actions.jsonl |
Action feed entries |
AuditStore |
logs/audits.jsonl |
Audit results |
PublishHistoryStore |
logs/publishes.jsonl |
Content publish history |
SchedulerHistoryStore |
logs/scheduler_history.jsonl |
Job execution history |
IssueTrackerStore |
logs/issues.jsonl |
Tracked issues |
GEOScanHistoryStore |
logs/geo.jsonl |
GEO scan history |
| Session Manager | sessions/ |
Agent session state |
Each implements append()/log(), list_all(), and latest_state() independently with no shared base class.
2.11 Memory & Context
memory/ contains static knowledge files loaded by memory_loader.py:
soul.md— Company identity/voicecompany.md— Company informationproduct.md— Product catalogpricing.md— Pricing informationskills.md— Team skills/capabilitieshistory.md— Company historycoding_standards.md— Code conventions
These are injected into LLM context by context_builder.py before each agent call. Files containing “TODO:” are skipped.
3. Data Flow: End-to-End Example
User types in dashboard: “Write a blog post about our new AI features”
POST /run→DashboardHandler.do_POST()→handle_runs_post()DashboardState.trigger_workflow("", "Write a blog post about...")- No workflow specified →
CEORouter.route_request(prompt)classifies via LLM →{"action": "workflow", "workflow": "content_drafting"} TaskRequest(task_type="content_drafting", prompt="...")createdOrchestrator.run(task)→route("content_drafting")→ confirms canonical nameWorkflowRunner.run():- Step 1: ResearchAgent + “Research the topic: AI features” → LLM call → research notes
- Step 2: ContentAgent + “Draft a blog post based on: {research}” → LLM call → draft
- Step 3: ReviewerAgent + “Review this draft: {draft}” → LLM call → review feedback
- Result written to
runs/<run_id>/result.md MultiChannelNotifier.send()→ Teams Adaptive Card with run summary- Dashboard shows run in recent runs table
4. Deployment Architecture
Production runs via Docker on a single VPS:
docker-compose.yml
├── dashboard (port 8000:8000) — standalone dashboard service
├── telegram (no ports) — standalone telegram bot
├── scheduler (no ports) — standalone scheduler loop
└── integrated (port 8001:8000) — dashboard + scheduler + telegram in one containerThe integrated service is the primary production target. It runs all three components as subprocesses inside a single container with a supervisor that monitors for failures. Deployment uses deploy-oneclick.ps1 which:
- Builds Docker image locally
- Pushes to AWS ECR
- SSHs into VPS, syncs config + scripts
- Runs
docker compose up -d --force-recreate integrated
SSH keys and AWS credentials are mounted into containers from the host (~/.ssh, ~/.aws).
5. Known Design Decisions & Trade-offs
-
No web framework — Dashboard uses stdlib
http.serverto avoid external dependencies. Trade-off: manual HTML generation, no templating, no session management. -
JSONL over SQLite — All persistent state is JSONL files. Simple to inspect/debug (
tail -f logs/approvals.jsonl), no schema migrations, but no query capabilities and risk of corruption on concurrent writes. -
Keyword-first routing — CEO router checks keyword patterns before calling the LLM. Reduces latency and cost for common commands, but the keyword list can drift from actual workflow names.
-
Two execution models — Most workflows use declarative
build()with steps;chatandaudit_fix_loopuserun_direct()with custom Python. The runner detects which at runtime. Flexible but means two code paths to maintain. -
OpenAI-compatible API with DeepSeek — The config labels the provider
"openai"but points toapi.deepseek.com. DeepSeek implements the OpenAI API format, so the same client code works. The label is misleading. -
Basic Auth without sessions — Simple to implement, no session storage needed. Trade-off: credentials sent on every request, no logout, no brute-force protection.
-
One container, three processes — The
integratedservice runs dashboard, scheduler, and telegram as subprocesses. Simpler than orchestrating three separate containers. Trade-off: one process crash can take down all three (mitigated by supervisor restart).