<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: blackmouse572</title>
    <description>The latest articles on DEV Community by blackmouse572 (@blackmouse572).</description>
    <link>https://dev.to/blackmouse572</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F881624%2Fddb56993-48ba-41bf-b6f4-403ab48ec7d0.jpeg</url>
      <title>DEV Community: blackmouse572</title>
      <link>https://dev.to/blackmouse572</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/blackmouse572"/>
    <language>en</language>
    <item>
      <title>Not another chatbot: we built an open-source agent harness for customer service</title>
      <dc:creator>blackmouse572</dc:creator>
      <pubDate>Fri, 02 Oct 2026 07:39:37 +0000</pubDate>
      <link>https://dev.to/blackmouse572/not-another-chatbot-we-built-an-open-source-agent-harness-for-customer-service-4cfo</link>
      <guid>https://dev.to/blackmouse572/not-another-chatbot-we-built-an-open-source-agent-harness-for-customer-service-4cfo</guid>
      <description>&lt;p&gt;Most customer service bots are a model, a prompt and a webhook. Ecbot is an agent harness for customer service: the runtime that sits between a model and your customers' chat apps, and decides what the model sees, what it's allowed to do, and when it has to stop and call a person.&lt;/p&gt;

&lt;p&gt;Coding agents made the word "harness" familiar. The model writes the code, but the harness gives it tools, manages its context, runs the loop and keeps it from doing damage. Customer service needs the same thing, with different tools and different dangers. Ecbot's harness gives the model a shop's documents, the shop's own APIs and a set of written playbooks. It is omnichannel, so it keeps one memory of each customer across every channel, and it hands the chat to a human when the customer gets angry or the model is unsure.&lt;/p&gt;

&lt;p&gt;There is no vendor lock-in. Ecbot routes through OpenRouter, so you can pick any model per agent, including DeepSeek and MiniMax, which keep the bill low and stay smart. The harness is the part you'd otherwise spend months writing.&lt;/p&gt;

&lt;p&gt;It's open source under AGPL-3.0, and it's the same engine we run as a hosted product, with every feature included. Six channels work today: Facebook Messenger, Zalo OA, WhatsApp Business, Telegram, a website widget and a REST API channel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Messenger · Zalo · WhatsApp · Telegram · Widget · REST
                         │  webhooks
 ┌───────────────────────▼────────────────────────────┐
 │ Harness                                            │
 │  durable inbox → dedupe → debounce → lease         │
 │  customer identity (one person, many apps)         │
 │  context: history + knowledge (RAG) + skills       │
 │  tools: your REST APIs, MCP servers, Composio      │
 │  guardrails in / out → handoff to operator inbox   │
 └───────────────────────┬────────────────────────────┘
                         │  stream
                   any LLM (OpenRouter)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Below are the five problems that made us build a harness instead of a chatbot, and the real code for each, so you can take the ideas even if you never run Ecbot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, briefly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;apps/api&lt;/code&gt;: NestJS 11, Postgres (MikroORM), Redis and BullMQ. Owns channels, conversations, knowledge, tools and the operator API.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apps/ai&lt;/code&gt;: Python 3.12, FastAPI and LangChain. Runs generation, RAG and guardrails. The API streams from it over HTTP.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apps/app&lt;/code&gt;: the operator inbox and dashboard (React 19, Vite, TanStack Query).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apps/edge&lt;/code&gt;: an optional Cloudflare Worker that receives platform webhooks. You can skip it and point webhooks at the API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Models go through OpenRouter, so you bring one key and pick the model per agent. Embeddings live in Postgres with pgvector, next to the rest of the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: the platform retries, and you restart
&lt;/h2&gt;

&lt;p&gt;Messenger and Zalo expect a fast &lt;code&gt;200&lt;/code&gt; on their webhook. If you're slow, they retry. If you &lt;code&gt;200&lt;/code&gt; first and process in memory, a deploy halfway through a reply loses the message for good.&lt;/p&gt;

&lt;p&gt;Ecbot writes every incoming message to a durable BullMQ job (Redis with AOF on) &lt;strong&gt;before&lt;/strong&gt; it acknowledges the webhook. A worker drains the queue. If the process dies mid-reply, the job is still there and runs again.&lt;/p&gt;

&lt;p&gt;Retries mean duplicates, so there is a single dedupe point at the top of the processing pipeline. It's a Redis &lt;code&gt;SET NX&lt;/code&gt; on &lt;code&gt;${platform}:${externalMessageId}&lt;/code&gt; with a 24-hour TTL. We put it there instead of relying on the BullMQ job id because messages can arrive by two paths (straight to the API, or forwarded by the edge Worker), and a single check covers both.&lt;/p&gt;

&lt;p&gt;For outages longer than a platform's retry window, adapters can implement an optional &lt;code&gt;reconcile(account, lookback)&lt;/code&gt; method that backfills missed messages on a schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: people type in bursts
&lt;/h2&gt;

&lt;p&gt;Chat users don't write paragraphs. They send four short messages in a row. Ecbot collapses messages that arrive inside a 3-second window into one &lt;strong&gt;debounce burst&lt;/strong&gt; and replies once, to all of them, while the platform shows a typing indicator.&lt;/p&gt;

&lt;p&gt;That solves most bursts. It doesn't cover the customer who sends a fourth message while the model is already streaming a reply to the first three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: the reply is already stale when it's ready
&lt;/h2&gt;

&lt;p&gt;For that case each conversation has a &lt;strong&gt;generation lease&lt;/strong&gt;: a counter in Redis. Every inbound message increments it. A generation records the value when it starts and checks it again while streaming and right before it sends. If the counter moved, a newer message exists, so the generation aborts and nothing goes out. The next turn answers with full context.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// GenerationLeaseService (simplified)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;bump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;epoch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`gen-lease:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`gen-lease:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;epoch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;isCurrent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;epoch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;epoch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ReplyGenerationService (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;streamChat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;turnContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;streaming&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deliver&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;isCurrent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isCurrent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;myEpoch&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aborting the &lt;code&gt;AbortController&lt;/code&gt; closes the HTTP stream between the API and the Python service, so the model stops generating and you stop paying for tokens nobody will read. The agent keeps no state between turns. Each turn rebuilds its context from the database, which is what makes throwing a generation away safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: one person, three apps
&lt;/h2&gt;

&lt;p&gt;A customer asks about a dress on Facebook, orders on WhatsApp, then asks on Telegram where it is. To most bots, that's three strangers.&lt;/p&gt;

&lt;p&gt;Ecbot separates the &lt;strong&gt;Customer&lt;/strong&gt; (the human, with name, phone, notes and tags) from the &lt;strong&gt;ContactPoint&lt;/strong&gt; (one platform identity, unique on &lt;code&gt;(workspace, platform, externalSenderId)&lt;/code&gt;). One customer has many contact points. Replies route through the contact point the message came from, while the profile, tags and history belong to the customer.&lt;/p&gt;

&lt;p&gt;We don't merge customers automatically. When two profiles share a phone number or email, Ecbot creates a merge suggestion and an operator confirms it. The agent never sees pending suggestions, only confirmed customers. A wrong auto-merge would leak one person's order history to another, and we didn't think a heuristic should make that call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: knowing when to stop
&lt;/h2&gt;

&lt;p&gt;An agent that sells also has to know when to hand the chat to a person. Ecbot's escalation fires when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the customer asks for a human (handoff keyword),&lt;/li&gt;
&lt;li&gt;the agent's confidence drops below a threshold you set,&lt;/li&gt;
&lt;li&gt;an input guardrail flags the message as prompt injection or abuse,&lt;/li&gt;
&lt;li&gt;the agent applies a customer tag you marked &lt;code&gt;triggersHandoff&lt;/code&gt;, such as "Angry".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Escalation turns the bot off for that conversation and notifies operators, who reply from the same inbox. When they resolve the conversation, the bot comes back on with a clean slate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools and skills: what the harness lets the model do
&lt;/h2&gt;

&lt;p&gt;The agent acts through &lt;strong&gt;tools&lt;/strong&gt;, which come in two kinds: HTTP tools that wrap your own REST endpoints (JSON schema in, description for the model), and MCP tools, either from the Composio marketplace or any MCP server URL you give it. Every call is stored as a &lt;code&gt;ToolInvocation&lt;/code&gt; with args, result, latency and errors, so an operator can see what the agent did in a conversation. Platform credentials are encrypted at rest (AES-256-GCM) and secrets are kept out of the model's context entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skills&lt;/strong&gt; are markdown playbooks: "when a customer asks for a refund: apologise, ask for the order code, check the order…". The agent only sees each skill's name and description. When a conversation needs one, it calls &lt;code&gt;load_skill(slug)&lt;/code&gt; and pulls the full text into context. That keeps the system prompt short even when a shop has twenty playbooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a channel
&lt;/h2&gt;

&lt;p&gt;Each channel is a self-contained adapter in &lt;code&gt;apps/api/src/modules/platform/adapters/&lt;/code&gt;. The base class is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PlatformAdapter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ENUM_ACCOUNT_TYPE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AdapterCapabilities&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;verifyChallenge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Headers&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;PlatformWebhookEvent&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;fetchSenderProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AccountEntity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;senderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PlatformUserProfile&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;reconcile&lt;/span&gt;&lt;span class="p"&gt;?(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AccountEntity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lookback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PlatformWebhookEvent&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...plus doSend() for outbound&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dedupe, debounce, the lease, guardrails and handoff sit above the adapter, so a new channel gets all of them for free. Instagram (&lt;a href="https://github.com/blackmouse572/ecbot/issues/22" rel="noopener noreferrer"&gt;#22&lt;/a&gt;), TikTok Shop (&lt;a href="https://github.com/blackmouse572/ecbot/issues/23" rel="noopener noreferrer"&gt;#23&lt;/a&gt;), Shopee (&lt;a href="https://github.com/blackmouse572/ecbot/issues/24" rel="noopener noreferrer"&gt;#24&lt;/a&gt;) and LINE (&lt;a href="https://github.com/blackmouse572/ecbot/issues/80" rel="noopener noreferrer"&gt;#80&lt;/a&gt;) are open and specced, labelled &lt;code&gt;good first issue&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;You need Node 20+, pnpm 9, Docker, and uv with Python 3.12+.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/blackmouse572/ecbot.git
&lt;span class="nb"&gt;cd &lt;/span&gt;ecbot
pnpm &lt;span class="nb"&gt;install
&lt;/span&gt;pnpm setup:local                 &lt;span class="c"&gt;# writes the .env files; asks for OPENROUTER_API_KEY&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;             &lt;span class="c"&gt;# Postgres + pgvector, Redis, JWKS, Bull Board&lt;/span&gt;
pnpm &lt;span class="nt"&gt;--filter&lt;/span&gt; api migration:up
pnpm dev:app                     &lt;span class="c"&gt;# api + operator UI&lt;/span&gt;
pnpm &lt;span class="nt"&gt;--filter&lt;/span&gt; ai dev             &lt;span class="c"&gt;# AI service, second shell&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI is on &lt;code&gt;localhost:5173&lt;/code&gt; and Swagger on &lt;code&gt;localhost:8080/docs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The honest catch: Facebook and Zalo need your own platform app with messaging permissions approved, and that review can take a while. Telegram, the website widget and the REST channel need no approval, so start with a Telegram bot. You can talk to your agent within a few minutes of the stack coming up.&lt;/p&gt;

&lt;p&gt;If you're building something similar, I'd like to hear how you handle stale replies. A per-conversation counter was the simplest thing that worked for us, and I'm sure it isn't the only way.&lt;/p&gt;

&lt;p&gt;The repo is at &lt;a href="https://github.com/blackmouse572/ecbot?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=ecbot-intro" rel="noopener noreferrer"&gt;github.com/blackmouse572/ecbot&lt;/a&gt;. Clone it, connect a Telegram bot, and tell us in the comments (or on &lt;a href="https://discord.gg/WaB5NjCycq" rel="noopener noreferrer"&gt;Discord&lt;/a&gt;) where it broke.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>typescript</category>
      <category>python</category>
    </item>
  </channel>
</rss>
