C++20 AI-agent orchestrator. A Poco HTTP server exposes a REST + WebSocket API and an MCP endpoint, drives an agent loop over pluggable LLM providers, and bridges the same agents onto messaging channels. The engine also builds as a shared library for the iOS/tvOS/watchOS xcframework and the Android aar.
Dependencies come through CPM: Poco (HTTP/TLS/XML/Zip), nlohmann/json, yaml-cpp, spdlog, OpenSSL, jwt-cpp, hnswlib, and llama.cpp (isolated). User language for chat is Brazilian Portuguese; code and comments are English.
make build— release server (build/bin/ionclaw-server).make test— configure with-DIONCLAW_BUILD_TESTS=ONand run the doctest suite (build/bin/ionclaw-tests). The suite covers pure helpers, internal-state round-trips, and stress/concurrency cases (many-thread producers/consumers, lock-scope races, persistence under concurrent writers). Add a test file totests/, list it intests/CMakeLists.txt, and keep every fix covered by a regression case. doctest cannot decompose&&/||insideCHECK— assign to a bool first. Guard any potential-infinite-loop regression with astd::async+wait_fortimeout so a regression fails instead of hanging CI.make build-web— Vue client intomain/resources/web(gitignored, embedded via CMake). Rebuild it before a cmake build when the web changed, then re-run cmake so the embed picks up the new asset hashes.make build-lib/make build-xcframework/make build-android— shared-library targets.
CI builds all six platforms on every push: macOS arm64, macOS x86_64, Linux x86_64, Windows x86_64, iOS xcframework, Android aar. A change is only done when all six are green.
Code and comments are in English. The code must read as if an experienced product C++ engineer wrote it, never like generated output. Do a change only when it makes sense and is genuinely needed, never to show work.
- No fallbacks, no backward-compatibility shims, no legacy code, no gambiarras, no dirty code, no dead code.
- Never keep a branch that exists only because the code used to work differently. Write the new, final version and refactor, remove, or rewrite whatever that requires, without worrying about compatibility.
- No generic fallback and no implicit unexpected behavior. Do not add an
elsefor an unknown case that would produce surprising behavior. Handle the known cases explicitly and surface anything else as a clear error. - Every error or failure is reported clearly. A failure the AI or the operator must see is logged and returned, never swallowed.
- All functions are class methods. Never a free function in a namespace, and never a function in an anonymous namespace. Generic or shared logic goes on a helper class as a
private staticor publicstaticmethod (StringHelper::foo,ToolHelper::bar). - No member
_suffix. Names carry the meaning, so correct naming replaces most comments. - Keep headers, implementation, namespaces, types, names, and responsibilities consistent with each other.
- When a method takes on too much responsibility, extract small objective methods. Do not extract just to shrink a function, and avoid artificial abstractions that obscure the main flow.
- These rules apply to every language in the repo, not just C++:
//in C++, Swift, Kotlin, and Dart, and#in CMake, Makefile, YAML, and shell all follow the same standard. No====/----banner rows and no capitalized section headers anywhere, including build files. - Comments are rare and used only where naming cannot carry the meaning. If it is obvious, decorative, or restates the code, delete it.
- A comment explains intent or context, not the literal code.
- Never add artificial section separators like
helpers,validators, orpublic methods. Never write header comments that describe a method, a member, or a section. - A
//or#one-line comment is lowercase and one objective sentence, with no semicolon splitting one sentence into clauses. - If a comment needs more than one line, finish each sentence with punctuation before the next line. Never continue one sentence onto the next line, and keep comments objective and natural rather than verbose or narrative.
- In a complex method, a short one-line comment can mark the intent of each meaningful block.
- Every log message carries a
[Component]prefix in PascalCase, e.g.[BrowserTool],[VectorStore], matching the class or subsystem that emits it. - The message after the prefix starts with a capital letter and reads as a sentence:
spdlog::warn("[AgentLoop] No provider found for '{}'", name). This is the opposite of the lowercase rule for//comments, which never carry a prefix. - Report every failure the operator or the AI must see; never swallow it. Keep the message objective and free of semicolon-split clauses, same as comments.
- Preserve the project's existing visual, structural, and architectural pattern. Keep it compact, professional, and consistent.
- Use only the vertical spacing needed to separate reading contexts, and always separate blocks of different responsibility with one blank line.
- Never clump multiple
ifs, validations, loops, state mutations, and returns together. A method should have a visually identifiable beginning, middle, and end and be understandable at a glance. - Prefer early returns and never write an
elseafter areturn. Avoid unnecessary nesting. - Keep includes clean, direct, and organized.
- Lambdas do not format well under clang-format, so wrap each lambda in
// clang-format off/// clang-format onwith the code already correctly formatted between the tags.
- Prefer
const, references, and smart pointers where they add clarity, safety, or correct ownership. - Avoid macros, unsafe casts, and raw pointers when a safer alternative consistent with the project exists.
- Read untrusted JSON defensively.
nlohmann::json::value(key, default)throws on a present-but-null or wrong-typed field, so guard withis_string()/is_number_integer()beforeget<T>(). - Timestamps are UTC. Engine state lives at the project root,
workspace/holds only agent-generated files, and YAML config keys and json state files use dashes, not underscores.
Entry: server/ServerInstance wires the components and owns them as statics. Request flow: HttpServer → RequestHandlerFactory picks a handler (ApiHandler for /api/*, McpHandler for MCP, WebhookHandler for /webhook/*, WebSocketHandler, PublicFileHandler for /public/*, WebAppHandler for the SPA). ApiHandler dispatches into Routes (split across server/routes/*). Auth gates everything except public and webhook paths.
- Providers (
provider/) —ProviderFactoryresolves aprovider/modelstring.AnthropicProvideris the native Anthropic client (extended thinking with signature replay before tool_use, redacted-thinking preserved, prompt-caching breakpoint gated by capability).OpenAiProviderserves every openai-compatible provider, including the thin-config providers whose base URLs the factory knows.LlamaProviderruns a local gguf via llama.cpp.ClaudeCliProviderdrives the local claude binary.FailoverProviderwraps auth profiles with backoff.ModelCapabilitiesloads the embedded litellm capability table (~2900 models) and drops unsupported params (reasoning, vision tool, tools payload, cache_control) before a request goes out. - Agent loop (
agent/) —Orchestratorserializes turns on one worker thread;AgentLoopruns the tool loop, streaming, compaction, and context management (ContextBuilder,ContextWindow,Compaction,ToolLoopDetector). - Tools (
tool/builtin/) — registered inToolRegistry, which validates params before dispatch and catches exceptions. Tools receive untrusted model-generated JSON args. - Channels (
channel/) — the logical channels areweb,telegram,whatsapp, andmcp. Inbound arrives onMessageBus; the agent reply is delivered per channel.webis answered over the WebSocket (EventDispatcherbroadcasts), so it takes no outbound queue.telegramandwhatsapphave runner threads that drain a per-channel outbound queue. WhatsApp has two providers behind the onewhatsappchannel:whatsapp_zapiandwhatsapp_meta, with Meta taking precedence. Meta webhooks are authenticated by theX-Hub-Signature-256HMAC; z-api cannot sign, so its webhook verifies an optional configuredwebhook_tokenpassed as?token=on the callback url. A reply to a non-web session reaches both the web ui (broadcast) and the origin channel (runner).DeliveryParserturns inline markers ([[image:path]],[[audio:path]],[[video:path]],[[document:path]],[[media:path]],[[break]]) into real attachments and message splits. - Embeddings and semantic memory (
embedding/,agent/SemanticMemory) —EmbeddingProviderhas an openai-compatible http implementation and a local llama.cpp implementation (in the isolatedionclaw-llamatarget, gated byIONCLAW_HAS_LLAMA_CPP).VectorStorekeeps vectors in an hnswlib index over normalized inner product (cosine), persisted with a metadata sidecar.SemanticMemorychunks memory files withTextChunkerand re-embeds only files whose content hash changed.memory_searchuses semantic search when an embeddings model is configured and its provider is available, otherwise keyword search. - Config (
config/) —ConfigStoreowns astd::shared_ptr<const Config>behind a mutex:snapshot()hands out the current immutable config for lock-free reads,replace()swaps a new one,update()copies-mutates-commits for the editing endpoints. Every component holds the store and snapshots at the top of an operation, so/api/config/restartcannot race a reader. YAML config files use dashes, not underscores. - State layout — engine state lives at the project root (
sessions/,tasks.json,cron-jobs.json,subagent-runs/,memory/), not underworkspace/.workspace/holds only agent-generated files.workspace/publicis served at{server.public_url}/public.
Deeper reference lives in docs/ (architecture, flow, configuration, custom-providers, whatsapp, mcp, llama, tools, known-limitations).
Threads: the Poco HTTP thread pool (concurrent request handlers), one orchestrator worker thread that runs every turn to completion, the telegram poll/outbound/typing threads, the whatsapp outbound thread, one sender thread per live WebSocket connection, the session sweeper, the heartbeat, the cron scheduler, and detached compaction threads. Shared state reached from more than one of these must be immutable after construction, atomic, or guarded by a mutex. Config is read only through ConfigStore snapshots. MessageBus and SessionQueue are internally synchronized.
A blocking call must not run while a shared lock is held. WebSocketManager::broadcast only enqueues onto each connection's bounded queue, and each connection drains its own queue on its own thread, so a slow client never stalls the orchestrator worker. CronService::tick snapshots the due jobs under its mutex, then creates tasks, publishes, and computes next-run times without it. ChannelManager::stopAll moves the runners out under the mutex and joins them outside it, so a 32s telegram long-poll never holds the manager mutex. ServerInstance::stop disables the MCP channel before joining the HTTP pool so a parked streaming handler releases its thread promptly.
- nlohmann
json::value(key, default)only substitutes the default when the key is absent — on an explicitnull(or a wrong type) it callsget<T>()and throws. Provider responses, stream chunks, tool args, webhook payloads, and request bodies routinely carrynullfields, so read them with anis_string()/is_number_integer()guard, never barevalue(). The same applies tonlohmann obj.value("k", object()).items(), whose temporary dangles inside a range-for — iterate a named member instead. nlohmann::jsonconstoperator[](key)is undefined behavior when the key is absent, so on aconst json&guard withcontains()first. The non-constoperator[](key)auto-vivifies a null (safe) but throwstype_error.305when the value is a non-object, non-null, so type-check each level before descending.- The web asset filenames are content-hashed; a stale cmake configure embeds the old names and the build fails. Rebuild the web, then re-run cmake.
SessionManagerreads a cold session file from disk while holdingglobalMutex, which briefly serializes the session cache during that load. Session files are small and capped, so this is a bounded latency, not a hang. Moving the read out of the lock would mean reworking the cache's use-after-free guarantees, so it stays as is until session files can grow large.