DEV Community

Anatoly (Vensus)
Anatoly (Vensus)

Posted on

Coreness Flow: Event-Driven AI Agent — No Cloud, No Code

Most AI assistants are just a chat: you type a question, you get an answer. Coreness Flow was built differently from the start. It's a local Windows application where an agent reacts to events — a message, a webhook, a cron schedule — and executes chains of actions defined in YAML. Want to change the agent's behavior? Edit the config, not the code. No cloud component: everything runs on your own machine.

Repository: github.com/Vensus137/Coreness-Flow

Note: The project and most of its documentation are currently in Russian.

The Idea: Not "Question–Answer", But "Event → Chain"

A standard AI chat is a synchronous loop: the user writes something, the model responds. Coreness Flow breaks that loop.

The central unit here is an event. A chat message arrives — that's an event. A cron fires at 9 AM — event. A webhook comes in from an external service — also an event. The scenario engine finds the matching scenario by trigger and executes a chain of steps: reads data, calls an LLM, searches the knowledge base, sends a reply. The same mechanism handles all sources.

This enables workflows that in a regular chatbot you'd have to implement in code — here everything is described in YAML: a daily digest on a schedule, an automatic webhook response, a reaction to a specific command with branching logic.

Agent behavior is defined by config and scenarios, not code.

Event Flow

Architecture: Three Layers, One Bus

The application is split into three layers that have no direct knowledge of each other — they communicate only through the API Bus.

  • UI Layer — Electron + React. The frontend is purely a view layer. It connects to the Python backend via WebSocket, sends actions, and receives events. No business logic in React — only rendering and bus calls.
  • Backend Layer — Python plugins. All logic lives here: message processing, LLM calls, database access, RAG, scheduler. Each plugin is isolated and communicates with others only through the API Bus.
  • API Bus — a unified contract between all participants. Two modes:
    • call — blocking call with a result (use when the next step depends on the outcome)
    • call_nowait — fire-and-forget background execution (for long chains, external requests)

Every call returns a unified response format: { result, error, response_data }. The scenario engine treats them all the same — it calls actions by name and reads the result.

Api Bus

Why This Separation

The most common architectural mistake in apps like this — UI knows about the database, business logic calls components directly. Add a new feature and you're doing surgery in three places at once.

In Coreness Flow, the UI has no idea which plugins are loaded. On startup, the frontend asks the backend for a description of what plugins contribute to the interface, then builds the screen from that data. A new plugin means a new tab — with zero frontend changes.

Add a plugin folder — the interface adapts automatically.

Lifecycle: How the App Starts


Electron main process
↓ spawn
Python backend
↓
API Bus initializes
↓
Container scans plugins/, merges configs, creates instances
↓
Heavy plugin initialization (splash shows progress)
↓
Plugin background tasks
↓
WebSocket server starts
↓
Main window opens, frontend connects
↓
Collect plugin descriptions → UI builds sidebar, tabs, settings

Enter fullscreen mode Exit fullscreen mode

Each stage is isolated. If a plugin crashes during initialization, the rest continue loading. Graceful shutdown: plugin and worker timeouts are set in app.json; the app closes cleanly on exit or update.

Plugins: VS Code-style, But for a Desktop App

The plugin system is one of the most interesting parts of the architecture. The idea is borrowed from VS Code: a plugin doesn't just add backend logic — it declares its contribution to the application via config.json.

Each plugin is a folder with two key files: config.json (what the plugin does and what it adds to the UI) and a Python module (how it's implemented). On startup, the container recursively scans plugins/ and loads every folder that contains a config.json. The folder name becomes the plugin identifier. No registry, no explicit module list in the core code.

config.json — The Heart of a Plugin

The config describes four things:

  • metadata — plugin name and description.
  • settings — settings schema with defaults. On startup, the container merges defaults from the config with user overrides from user_settings.json. The plugin always receives the final merged config.
  • actions — the list of actions the plugin registers in the API Bus. Each action has a defined input (payload schema) and output.
  • contributes — what the plugin adds to the UI. This is the most interesting part.

Python class methods whose names match keys in actions automatically become call handlers — no explicit registration needed. Declare an action in the config and implement a method with the same name — the engine wires them together on plugin load.

Config + method with the same name — a binding with no explicit registration.

Contributes: UI From Config

Four contribution points:

Point What it adds
workspace A tab in the main content area with a widget (list, settings form, etc.)
settings A section on the shared "Settings" tab
sidebar An item in the sidebar (click triggers an action)
menus Items in dropdown menus

The widget type for a tab is described with a descriptor: settingsForm, list — the frontend renders content without any custom code. List columns, data source via action, form fields — all in the plugin's JSON config. The "Vector Store" tab with a chunk list and delete buttons is defined in the plugin like this:

"contributes": {
  "workspace": {
    "id": "vector_store_admin",
    "label": "Vector Store",
    "title": "Vector Store",
    "content": {"widget": "vectorStoreAdmin"}
  }
}
Enter fullscreen mode Exit fullscreen mode

vectorStoreAdmin is a built-in widget type registered in the frontend; the plugin only references it by name. Not a single line of React in the plugin — just config.

This creates a clear separation of concerns: a backend developer writes the plugin and its config, the UI layer adapts itself. Want to remove a tab? Delete the plugin folder. Want to rename it? Change label in the config.

Hot Reload for Settings

A nice touch: when a user changes settings in the UI, the backend notifies the affected plugin — it recreates clients and clears caches without restarting the app. Change an API key or model — the plugin picks it up immediately.

Scenarios: Orchestration Without Code

If plugins are services with actions, scenarios are the way to orchestrate those actions without writing code. All scenarios live in YAML files in config/scenarios/. The engine picks them up recursively from all subdirectories. You can organize them however you like: commands/, system/, scheduled/ — scenario names are global: any scenario can call another by name.

Basic structure:

daily_report:
  schedule: "0 9 * * *"   # Every day at 9:00
  step:
    - action: "get_storage"
      params:
        group_key: "report_config"
        _response_key: "config"
    - action: "completion"
      params:
        prompt: "Generate a morning digest. Context: {_cache.config}"
        model: "{_cache.config.model}"
    - action: "send_chat_message"
      params:
        text: "{_cache.response}"
Enter fullscreen mode Exit fullscreen mode

Each step is a plugin action call. The result is placed in _cache under the key _response_key. The next step reads data via the {_cache.key} placeholder.

Placeholders — Lightweight Templating

Placeholders work in all step parameters and support a modifier chain:

  • {event_text} — the event's text content
  • {_cache.system.routing_model} — a nested field from the cache
  • {now|format:datetime} — current time with formatting
  • {_cache.field|fallback:default} — value with a default if the field is empty
  • {_cache.result|exists} — boolean: whether the field exists

This enables flexible chains without any Python code — just value substitution through templates.

Triggers: From Simple to Complex

The simplest trigger form — event type plus text:

trigger:
  - event_type: "message"
    event_text: "/help"
Enter fullscreen mode Exit fullscreen mode

The complex form — a condition field with an expression in a mini-language (operators: ==, ~ for "contains", regex, is_null, etc.; fields via $event_text, $event_type):

trigger:
  - event_type: "message"
    condition: "$event_text ~ '/'"
Enter fullscreen mode Exit fullscreen mode

Multiple triggers in the list work as OR. Fields within a single trigger work as AND. No need to duplicate a scenario for every variant — just add a trigger to the list.

Transitions and Branching

After a step, you can define a transition — a list of rules: based on the action result (action_result: success, error, etc.), a transition is executed (transition_action and optionally transition_value). For example, jumping to another scenario:

- action: "search_chunks"
  params:
    query: "{event_text}"
    _response_key: "rag_result"
  transition:
    - action_result: "success"
      transition_action: "jump_to_scenario"
      transition_value: "step_with_rag"
    - action_result: "error"
      transition_action: "jump_to_scenario"
      transition_value: "step_without_rag"
Enter fullscreen mode Exit fullscreen mode

Found something in RAG — go to one scenario; not found — go to another. All in config.

Step chains and branching — in YAML, no code.

A typical utility scenario: fetch a document by URL, split it into chunks, store in the vector database; on error — transition to an error-handling scenario.

Configuration: Merging Without Magic

The configuration schema is intentionally simple and uniform throughout:

  • App defaultsconfig/app.json
  • Plugin defaults — the settings section in the plugin's config.json
  • User overrides%APPDATA%\CorenessFlow\user_settings.json (changed keys only)

On startup, the container merges defaults with user values and passes the final config to each plugin. Secrets and API tokens are stored in SQLite in %APPDATA% — they never end up in the repository.

The same config.json format is used for both the application and every plugin — the container code is unified, and documentation stays consistent.

Storage: Key–Value for Agent Configuration

The database plugin gives scenarios a simple key–value store with grouping: group_key + key → value. But the more interesting part: initial data is defined directly in YAML files in config/storage/ and synchronized into SQLite on startup.

This makes agent behavior configurable without editing scenarios. The router's system prompt, available tools list, model parameters, limits — all stored in storage, scenarios read this data at runtime. Changing the model for simple requests means editing storage, not YAML scenarios.

Agent Routing: How One Request Becomes a Chain of Decisions

Agent routing in Coreness Flow is not a built-in core function — it's a set of system scenarios layered on top of the general mechanism. The user sees: sent a message → "Processing..." → response. Behind the scenes — a chain of several scenarios and multiple LLM calls.

Message Processing Pipeline

AI-routing

When a chat message arrives, the following sequence runs:

  1. Load context — settings are read from storage: system prompt, list of tools and response scenarios, step limit.
  2. Assemble history — chat history is fetched with a size limit; relevant RAG fragments are optionally injected.
  3. Routing — an LLM request with the prompt and tool descriptions. The model decides: invoke one of the tools (e.g., knowledge base search) or generate a direct response.
  4. Execution — if a tool is selected, the corresponding scenario runs, then routing again (loop until step limit).
  5. Finalization — a final model request, and the response is sent to the chat.

New tool = a description in the config + a scenario file; no core changes.

Tools are just YAML scenarios: adding a new one means describing it in the storage config and adding a scenario file. The application core is untouched.

Chat

Local RAG: No Servers, No Cloud

The vector store in Coreness Flow is a separate plugin with several key design decisions.

RAG

BGE-M3 in ONNX Format, INT8 Quantization

A multilingual model that generates both dense and sparse vectors simultaneously. The model runs via ONNX Runtime — no PyTorch, no CUDA. INT8 quantization reduces memory consumption by roughly 4× compared to float32 with minimal quality loss. Runs on a regular CPU.

Qdrant in Embedded Mode

Qdrant runs inside the application process and persists data to disk. No separate service, no ports, no Docker needed.

Hybrid Search with RRF

BGE-M3 produces both dense vectors (semantics) and sparse vectors (keywords). Searching across both types and merging results via Reciprocal Rank Fusion delivers better quality than semantic-only search — especially for queries with specific terms and proper nouns.

In scenarios this is two actions: add_chunks for indexing and search_chunks for retrieval. The search result is placed in _cache and substituted into the next step's prompt. Documents never leave the machine.

Model Loading and the Splash Screen

BGE-M3 is a heavy model, and loading it on startup requires a dedicated solution. It loads before the main window is shown: the user sees a splash screen with initialization progress; the main window opens only when everything is ready. No frozen UI.

Splash screen on startup

Async Without the Pain

Actions in the API Bus execute in a worker pool — separate threads with their own event loop. The number of workers is configurable. This means a long LLM call doesn't block processing of another incoming event.

call_nowait is particularly useful. You kick off a long scenario chain, don't wait for it to finish, and continue. The result arrives as an event in the UI. This is how the entire chat works: the user sends a message, the backend launches the chain via call_nowait, the UI shows a loading indicator and never blocks.

Plugins subscribe to bus events. This enables reaction to any system events without direct dependencies between plugins.

Project Structure

Coreness-Flow/
├── run_backend.py          # Backend entry point
├── app/
│   ├── runtime/
│   │   ├── container.py    # Plugin scanning, config merging, instantiation
│   │   ├── api_bus.py      # Bus: actions, events, workers
│   │   └── ...
│   ├── settings.py         # Config loading and merging
│   └── ws_server.py        # WebSocket for the frontend
├── frontend/               # Electron + React
├── plugins/
│   ├── core/               # Core modules: chats, database, ai_service, vector_store, ...
│   ├── base/               # Non-critical plugins
│   └── extensions/         # Custom extensions
└── config/
    ├── app.json            # App defaults
    ├── scenarios/          # YAML scenarios
    └── storage/            # Initial storage data
Enter fullscreen mode Exit fullscreen mode

core/ contains the foundation: chats, database, LLM calls, vector store, scenario engine. base/ holds additional plugins — the app runs without them. extensions/ is where you put your own.

Quick Start

From source (Python 3.11, Node.js, Windows):

pip install -r requirements.txt
cd frontend && npm install && cd ..
.\scripts\run-dev.ps1
Enter fullscreen mode Exit fullscreen mode

Backend and window start with a single command. In dev mode: hot reload on config and plugin code changes.

From releases: a Windows installer is available under Releases — no Python or Node needed on the target machine.

After the first launch: set your AI provider in settings (any OpenAI-compatible API), optionally load documents into the vector store, and configure storage to fit your needs.

Stack

Component Technology
Frontend Electron + React
Backend Python 3.11, async/await
UI ↔ Backend WebSocket + API Bus
LLM Any OpenAI-compatible API (OpenRouter, etc.)
RAG BGE-M3 ONNX INT8 + Qdrant embedded
Storage SQLite + JSON (config) + YAML (scenarios, storage)
Build Electron Builder + Python backend

Key Design Decisions

What sets Coreness Flow apart:

  • Plugins without a registry — a folder with a config and module; the container picks it up on startup; unified contract for everything.
  • UI from config — plugins declare tabs, settings, menu items; the frontend assembles the UI from this data, no plugin list in code.
  • Scenarios instead of code — action orchestration in YAML: triggers, steps, result-based transitions; the engine calls actions by name.
  • Local RAG — embeddings and vector store on your own machine, ONNX and Qdrant in-process, offline.
  • Events and bus — plugins communicate only through the API Bus and subscribe to events; no direct calls between modules.

Links

Coreness — Create. Automate. Scale.

Top comments (0)