Part 1 stopped at the point where NemoClaw was finally running cleanly on GreenNode AgentBase: the image was built from NVIDIA’s repo, the port 8080 health check wrapper was in place, the runtime was ACTIVE, and a 27B model was answering behind a TLS endpoint. That solved the “can it run here at all?” question. What it didn’t solve was “how does a real user actually talk to it?”. 

Part 2 picks up exactly from there and adds that missing communication layer: a separate Telegram bridge runtime that receives messages from Telegram, calls GreenNode’s MaaS API directly, and sends replies back to chat — along the way documenting why this has to be a standalone runtime, how the outbound proxy and webhook behaviour change the design, and which new pitfalls only appear once you put a bot in front of NemoClaw.

Live result: @NemoClaw_tungvt6bot responds to Telegram messages, backed by qwen/qwen3-5-27b via GreenNode MaaS.

What We’re Building

A separate Telegram bridge runtime — a lightweight Python FastAPI app that:

  • Receives Telegram webhook updates at POST /invocations.
  • Calls the GreenNode MaaS LLM API directly.
  • Sends the reply back to Telegram via sendMessage.

This runs as its own AgentBase runtime, nemoclaw-telegram-bridge, completely separate from the NemoClaw backend runtime.

Architecture

User
└─► Telegram servers
    └─► POST /invocations (webhook, inbound — always works)
        └─► nemoclaw-telegram-bridge runtime
            └─► GreenNode MaaS LLM API (qwen/qwen3-5-27b)
                └─► api.telegram.org/sendMessage (outbound — Python httpx works)

Why a separate bridge runtime instead of putting Telegram directly inside nemoclaw-v2? NemoClaw’s Node.js runtime on a 2x4 flavor container cannot reliably reach api.telegram.org outbound because GreenNode routes outbound traffic through a transparent proxy at 10.200.0.1:3128, which breaks Node.js’s undici HTTP client with UND_ERR_CONNECT_TIMEOUT. Python’s httpx library handles that proxy transparently, so the bridge is written in Python.

The bridge also uses the webhook pattern instead of polling. Telegram pushes updates into the runtime, which means inbound delivery works even if outbound connectivity is limited for message retrieval.

Known Pitfalls

These are the failures hit during the real deployment. The fixes are all baked into the steps below.

#PitfallSymptomFix
1Outbound blocked on 2x4 flavorBot never replies; no obvious errorsUse Python httpx, not Node.js, and use webhook mode instead of polling.
2Calling nemoclaw-v2 via AGENTBASE_INVOCATIONS_URL returns 404Bot replies “Sorry, something went wrong”Skip the proxy pattern and call the MaaS LLM API directly from the bridge.
3Angular password field ignores native setter and keyboard inputSAVE stays greyed out; deploy failsSet password only via document.execCommand('insertText', false, pwd).
4Use agent base registry credentials must be uncheckedFailed to pull image with correct robot credentialsUncheck the box so GreenNode does not substitute its own internal credentials.
5LLM API key truncated in the runtime UI401 Unauthorized from MaaS APICopy the full key from the API Keys dialog using the DOM method.
6extra_body in raw httpx is sent as a literal JSON keyModel rejects or ignores the requestPut chat_template_kwargs at the top level of the JSON body.
7Telegram bot token baked into the Docker imageSecurity exposurePass token only as a GreenNode env var: TELEGRAM_BOT_TOKEN.
8Qwen3 thinking tokens inflate responsesResponse includes <think>...</think> blocksAdd "chat_template_kwargs": {"enable_thinking": false} to the LLM request body.

Pitfall 1: Outbound Blocked on 2x4 Flavor

GreenNode’s 2x4 flavor containers route outbound traffic through a transparent proxy at 10.200.0.1:3128. Node.js’s built-in HTTP client, undici, does not handle this proxy cleanly, which causes outbound connections to api.telegram.org to time out with UND_ERR_CONNECT_TIMEOUT. Python’s httpx respects the proxy automatically, which is why the bridge app is written in Python.

The webhook pattern also helps because Telegram sends updates to the runtime. The bridge does not need to poll Telegram just to receive messages.

Pitfall 2: AGENTBASE_INVOCATIONS_URL Proxy Pattern Returns 404

An intuitive first attempt is to route the bridge through nemoclaw-v2: receive a Telegram message, call NemoClaw’s invocations URL, and return the answer. That pattern does not work here. The AGENTBASE_INVOCATIONS_URL environment variable that GreenNode injects points to a URL shape that returned 404 for this cross-runtime call pattern during deployment.

The fix is to make the bridge fully self-contained and call the GreenNode MaaS API directly at https://maas-llm-aiplatform-hcm.api.vngcloud.vn/v1/chat/completions.

Pitfall 3: Angular Password Field

The GreenNode Edit Runtime form uses Angular reactive forms. The password field rejects both direct keyboard simulation and the native JavaScript value setter plus dispatched input events, because Angular’s internal form state does not register those updates.

The only method that worked consistently was:

const pwInput = document.querySelectorAll('input[type=password]')[0];
pwInput.focus();
pwInput.select();
document.execCommand('insertText', false, 'YOUR_PASSWORD');

execCommand('insertText') triggers Angular’s change detection through the browser’s native text input pipeline, which Angular hooks at a lower level than a synthetic dispatchEvent.

Pitfall 4: Use Agent Base Registry Credentials Must Be Unchecked

The Edit form has two checkboxes:

  • Image authentication — must be checked.
  • Use agent base registry credentials — must be unchecked.

When that second box is checked, GreenNode substitutes its own internal credentials for the vCR registry and ignores the robot credentials entered in the form. Those built-in credentials do not have access to the private repository, so the image pull fails.

Pitfall 5: LLM API Key Truncated in UI

The runtime environment variable form may display a visually truncated API key even though the actual value is longer. During deployment, the visible value read from the edit form was 80 characters, while the real key was 96 characters. Those missing characters caused every MaaS call to fail with 401 Unauthorized.

Get the full key from the API Keys page and read it from the DOM in the “View API Key” dialog:

Array.from(document.querySelectorAll('*'))
  .filter(el => el.children.length === 0 && el.textContent.trim().startsWith('vn-'))
  .map(el => el.textContent.trim())[0];

Pitfall 6: extra_body in Raw httpx vs LangChain

LangChain’s ChatOpenAI treats extra_body specially and merges it into the request body. Raw httpx does not. If extra_body is sent literally, the API may ignore or reject it.

In a direct httpx request, put the fields at the top level:

payload = {
  "model": LLM_MODEL,
  "messages": messages,
  "chat_template_kwargs": {"enable_thinking": False}
}

Pitfall 7: Bot Token Security

The Telegram bot token must never be baked into the Docker image. Once pushed to a registry, image layers can be inspected and secrets can be extracted from them. Pass the token only as a GreenNode environment variable, TELEGRAM_BOT_TOKEN.

Pitfall 8: Qwen3 Thinking Tokens

Qwen3 models support a thinking mode that emits <think>...</think> blocks before the final answer. That increases latency and leaks reasoning-style output into the user-facing response. Disable it with:

"chat_template_kwargs": {"enable_thinking": False}

Prerequisites

  • Part 1 complete with nemoclaw-v2 ACTIVE, although the bridge can also run independently.
  • A Telegram bot token from @BotFather.
  • A GreenNode MaaS API key with the full 96-character value.
  • A vCR robot account with push access to the repository.
  • Docker Desktop running locally.

Step-by-step integration 

Prefer to skip writing the bridge from scratch? Thu Vo, our Head of AI Lab, has published a complete, ready-to-deploy bridge at github.com/votrongthu/NemoClaw_ThuVT. Clone the repo, drop your tokens in, and you're done — no code to write. 
It includes everything you need out of the box:

  • main.py, Dockerfile, and a pre-configured .env.example for GreenNode MaaS
  • Typing indicator, per-chat history, /start and /reset commands
  • TELEGRAM_ALLOWED_IDS allowlist and paragraph chunking

If that works for you, skip Step 1 and go straight to Step 2.

Step 1: Create the Bridge Application

Create a new directory for the bridge source:

telegram-bot-src/
├── main.py
├── requirements.txt
└── Dockerfile

requirements.txt

fastapi>=0.115.0
uvicorn>=0.34.0
httpx>=0.28.0

Dockerfile

FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

Unlike the NemoClaw backend, this image needs no special wrapper because FastAPI with uvicorn already serves on port 8080.

main.py

import os, asyncio, logging, httpx
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_API = f"https://api.telegram.org/bot{BOT_TOKEN}"
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://maas-llm-aiplatform-hcm.api.vngcloud.vn/v1")
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
LLM_MODEL = os.environ.get("LLM_MODEL", "qwen/qwen3-5-27b")
SYSTEM_PROMPT = os.environ.get(
    "SYSTEM_PROMPT",
    "You are NemoClaw, an intelligent AI assistant. Be helpful, concise, and friendly.",
)
_conversations: dict[int, list] = {}
MAX_HISTORY = 20
@asynccontextmanager
async def lifespan(app: FastAPI):
    logger.info("NemoClaw Telegram bridge starting...")
    logger.info(f"LLM: {LLM_MODEL} @ {LLM_BASE_URL}")
    yield
    logger.info("Shutting down.")
app = FastAPI(lifespan=lifespan)
@app.get("/health")
async def health():
    return {"status": "ok"}
async def call_llm(chat_id: int, user_message: str) -> str:
    history = _conversations.setdefault(chat_id, [])
    history.append({"role": "user", "content": user_message})
    if len(history) > MAX_HISTORY:
        _conversations[chat_id] = history[-MAX_HISTORY:]
        history = _conversations[chat_id]
    messages = [{"role": "system", "content": SYSTEM_PROMPT}] + history
    payload = {
        "model": LLM_MODEL,
        "messages": messages,
        "max_tokens": 2000,
        "temperature": 0.7,
        "chat_template_kwargs": {"enable_thinking": False},
    }
    async with httpx.AsyncClient(timeout=55.0) as client:
        resp = await client.post(
            f"{LLM_BASE_URL}/chat/completions",
            headers={
                "Authorization": f"Bearer {LLM_API_KEY}",
                "Content-Type": "application/json",
            },
            json=payload,
        )
        logger.info(f"LLM response: HTTP {resp.status_code}")
        if resp.status_code != 200:
            logger.error(f"LLM error body: {resp.text[:500]}")
            resp.raise_for_status()
        data = resp.json()
        assistant_message = data["choices"][0]["message"]["content"]
        history.append({"role": "assistant", "content": assistant_message})
        return assistant_message
async def send_message(chat_id: int, text: str):
    for parse_mode in ["Markdown", None]:
        try:
            chunks = [text[i:i + 4096] for i in range(0, len(text), 4096)]
            async with httpx.AsyncClient(timeout=15.0) as client:
                markdown_failed = False
                for chunk in chunks:
                    payload: dict = {"chat_id": chat_id, "text": chunk}
                    if parse_mode:
                        payload["parse_mode"] = parse_mode
                    resp = await client.post(f"{TELEGRAM_API}/sendMessage", json=payload)
                    if resp.status_code == 400 and parse_mode:
                        markdown_failed = True
                        break
                if not markdown_failed:
                    return
        except Exception as e:
            logger.error(f"send_message error (parse_mode={parse_mode}): {e}")
            return
async def process_message(chat_id: int, user_id: int, text: str):
    try:
        reply = await call_llm(chat_id, text)
    except Exception as e:
        logger.error(f"LLM error for chat {chat_id}: {type(e).__name__}: {e}")
        reply = "Sorry, something went wrong. Please try again."
    if reply:
        await send_message(chat_id, reply)
@app.post("/invocations")
async def webhook(request: Request):
    try:
        update = await request.json()
    except Exception:
        return Response(status_code=200)
    message = update.get("message") or update.get("edited_message")
    if not message:
        return {"ok": True}
    text = message.get("text", "").strip()
    chat_id = message.get("chat", {}).get("id")
    user_id = message.get("from", {}).get("id")
    if not text or not chat_id:
        return {"ok": True}
    if text == "/start":
        await send_message(chat_id, "Hi! I'm NemoClaw, your AI assistant. How can I help you?")
        return {"ok": True}
    if text == "/clear":
        _conversations.pop(chat_id, None)
        await send_message(chat_id, "Conversation history cleared.")
        return {"ok": True}
    asyncio.create_task(process_message(chat_id, user_id, text))
    return {"ok": True}

Key design decisions

  • POST /invocations returns HTTP 200 immediately and processes the LLM call in the background because Telegram retries if no response is returned within about 5 seconds.
  • Conversation history is stored in memory per chat_id, which means it resets on container restart.
  • /start and /clear are handled without calling the LLM.

Step 2: Build and Push the Bridge Image

# From the telegram-bot-src directory
docker build -t vcr.vngcloud.vn/YOUR-ORG/telegram-bot:v1 .
docker push vcr.vngcloud.vn/YOUR-ORG/telegram-bot:v1

This image builds quickly because it is just Python plus three small dependencies. Always increment the image tag on each push.

Step 3: Deploy the Bridge Runtime

Navigate to GreenNode AgentBase → Deploy a new Agent → Custom Agent.

Runtime settings

FieldValue
Agent runtime namenemoclaw-telegram-bridge
Image URLvcr.vngcloud.vn/YOUR-ORG/telegram-bot:v1
Flavorruntime-s2-general-2x4
Min/Max replicas1 / 1

Image authentication

  • Check Image authentication.
  • Leave Use agent base registry credentials unchecked.
  • Username: your vCR robot account name.
  • Password: your vCR robot account password.

If you need to set the password from the browser console because of the Angular form issue, use:

const pw = document.querySelectorAll('input[type=password]')[0];
pw.focus();
pw.select();
document.execCommand('insertText', false, 'YOUR_PASSWORD');

Environment variables

KeyValueNotes
TELEGRAM_BOT_TOKENYOUR_BOT_TOKENFrom BotFather. Never bake it into the image.
LLM_BASE_URLhttps://maas-llm-aiplatform-hcm.api.vngcloud.vn/v1GreenNode MaaS endpoint.
LLM_API_KEYYOUR_FULL_96_CHAR_KEYUse the full value from the API Keys page.
LLM_MODELqwen/qwen3-5-27bOr another available MaaS model.

Click SAVEConfirm. Wait for the runtime to go ACTIVE. 

Step 4: Register the Telegram Webhook

Once the bridge runtime is ACTIVE, copy the endpoint URL from the runtime detail page, for example:

https://endpoint-XXXX.agentbase-runtime.aiplatform.vngcloud.vn

Register it as the Telegram webhook:

curl "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook" \
  --data-urlencode "url=https://endpoint-XXXX.agentbase-runtime.aiplatform.vngcloud.vn/invocations"

Expected response:

{"ok": true, "result": true, "description": "Webhook was set"}

Verify the webhook registration:

curl "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getWebhookInfo"

The url field should show the runtime endpoint plus /invocations, and pending_update_count should be 0 if updates are being processed correctly. 

Step 5 : Test the Bot

Open Telegram and send a message to the bot. Expected behavior:

  • The bot shows a typing indicator quickly.
  • The LLM responds within roughly 5–30 seconds depending on model load.
  • The reply arrives back in the chat.

Supported commands:

  • /start — sends a greeting without calling the LLM.
  • /clear — clears conversation history for the current chat.

In GreenNode runtime logs, expected entries include:

INFO:main:LLM request: POST .../chat/completions model=qwen/qwen3-5-27b msgs=2
INFO:main:LLM response: HTTP 200
INFO: ... - "POST /invocations HTTP/1.1" 200 OK

If the logs show HTTP 401, the API key is wrong or truncated. If there are no invocation logs at all, the webhook is not registered correctly. 

Step 6: Updating the Bridge

When main.py changes:

docker build -t vcr.vngcloud.vn/YOUR-ORG/telegram-bot:v2 .
docker push vcr.vngcloud.vn/YOUR-ORG/telegram-bot:v2

Then in GreenNode:

  • Click Edit on the nemoclaw-telegram-bridge runtime.
  • Update Image URL to :v2.
  • Re-enter the vCR password.
  • Ensure Use agent base registry credentials is still unchecked.
  • Click SAVEConfirm.

The webhook URL stays the same, so there is no need to register it again. 

Troubleshooting

Bot never responds / no /invocations logs

Cause: Webhook not registered or pointing to the wrong URL.

Fix: Run getWebhookInfo, verify the registered URL, then run setWebhook again with the correct endpoint and /invocations suffix. 

401 Unauthorized in LLM logs

Cause: LLM_API_KEY is wrong or truncated.

Fix: Get the full 96-character key using the DOM method from the API Keys dialog. 

Failed to pull image when deploying

Cause A: Use agent base registry credentials is checked.

Cause B: The vCR password was not registered by Angular.

Fix: Uncheck the box and use execCommand('insertText') if needed. 

Bot replies immediately with “Sorry, something went wrong”

Cause: The LLM call is failing.

Fix: Check runtime logs for the HTTP status and error body. Common sub-causes:

  • 401 — wrong API key.
  • 404 — wrong LLM_BASE_URL.
  • 422 or 400 — malformed request body, often because chat_template_kwargs was placed under extra_body.

Response contains <think>...</think> blocks

Cause: Qwen3 thinking mode was not disabled.

Fix: Ensure "chat_template_kwargs": {"enable_thinking": False} is present at the top level of the request body. 

Summary of Running Runtimes

RuntimeImagePurpose
nemoclaw-v2nemoclaw:v16NemoClaw backend for OpenClaw agents and sandbox execution.
nemoclaw-telegram-bridgetelegram-bot:v5Telegram webhook bridge to GreenNode MaaS.
telegram-bot(DO NOT TOUCH)Separate OpenClaw bridge.
openclaw-agent(DO NOT TOUCH)Separate agent runtime.

The nemoclaw-telegram-bridge runtime is fully independent. It does not call nemoclaw-v2 and does not require it to be running. 

Telegram bridge added 2026-06-12. Runtime nemoclaw-telegram-bridge, Version 10.