Files
nofx/docs/plans/2026-03-06-telegram-agent-redesign.md
T
tinkle-community 9c5c976d9a feat: Claw402 x402 payment provider + Telegram agent + x402 refactoring (#1409)
* feat(telegram): add AI agent bot with streaming and account context

- Add Telegram bot with long-polling and AI agent loop (api_call tool)
- SSE streaming with real-time message editing and  placeholder
- Account state injection at conversation start (models, exchanges,
  strategies, traders, per-trader PnL and statistics)
- Lane semaphore per chat serializes concurrent messages (60s timeout)
- Idle timeout watchdog (60s) prevents hung streaming connections
- Look-ahead buffer prevents partial <api_call> tag leaking to user
- Fix PUT /strategies/:id to merge config (read-then-merge pattern)
- Add route registry with full API schema for LLM documentation
- Add TelegramConfig store and Web UI config modal
- Add GetAnyEnabled to AIModel store for bot LLM client selection

* fix(telegram): eliminate narration, add full-setup workflow and tests

- Rewrite NO NARRATION rule: response is EITHER api_call tag alone OR
  final text reply — no text before api_call under any circumstances
- Ban all narration patterns: 现在我将/好的/正在/I will/Let me etc.
- Add 'create strategy + create trader + start' full setup workflow
- Add 12 automated tests covering:
  - No narration leaking to user (5 narration variants tested)
  - api_call tag never leaks to user
  - Full setup workflow: POST strategy → verify → POST trader → start
  - Start existing trader workflow
  - Max iterations safety, tag stripping, parser edge cases

* refactor(agent): replace XML api_call with native function calling

Migrate the Telegram bot agent from an XML tag hack (<api_call>) to
OpenAI-native function calling via CallWithRequestFull.

Key changes:
- mcp/interface.go: add parseMCPResponseFull to clientHooks interface
- mcp/client.go: route callWithRequestFull through hooks for overridability
- mcp/claude_client.go: override parseMCPResponseFull for Claude response
  format (tool_use blocks instead of choices[].message.tool_calls)
- telegram/agent/agent.go: rewrite Run() to use CallWithRequestFull;
  define api_request tool with JSON Schema; implement tool-call loop
  with role="tool" result messages; remove XML parsing entirely
- telegram/agent/apicall.go: remove parseAPICall (dead code)
- telegram/agent/prompt.go: simplify — remove XML format instructions,
  replace with concise api_request tool usage instructions
- telegram/agent/agent_test.go: rebuild all tests using LLMResponse
  objects; add TestNarrationStructurallyImpossible, TestOnChunkCalledWithFinalReply,
  TestToolCallIDPropagated; remove XML-specific tests

Architecture advantage: with native function calling, the LLM returns
EITHER ToolCalls OR Content — never both. Narration is now structurally
impossible at the protocol level, not just enforced by prompt rules.

All 11 agent tests pass. mcp package tests pass.

* refactor(mcp): route buildRequestBodyFromRequest through hooks + full Anthropic format

Problem: callWithRequest/Full/Stream all called client.buildRequestBodyFromRequest
directly (not via hooks), so ClaudeClient could never override it. This meant
tool calling sent OpenAI format to Anthropic (wrong field names, wrong roles).

Changes:

mcp/interface.go
- Add buildRequestBodyFromRequest(*Request) map[string]any to clientHooks
- Improve comments: document what each hook group does and why

mcp/client.go
- All three paths (callWithRequest, callWithRequestFull, CallWithRequestStream)
  now call client.hooks.buildRequestBodyFromRequest — ClaudeClient picks up

mcp/claude_client.go
- Full rewrite with format comparison table in package doc
- buildRequestBodyFromRequest: produces correct Anthropic wire format
    * system prompt → top-level "system" field
    * tools: parameters → input_schema, no "type:function" wrapper
    * tool_choice "auto" → {"type":"auto"} object
    * assistant tool calls → content[{type:tool_use, id, name, input}]
    * role=tool results → role=user content[{type:tool_result,...}]
    * consecutive tool results merged into single user turn
- convertMessagesToAnthropic: handles all three message types
- parseMCPResponseFull: extracts text + tool_use blocks
- parseMCPResponse: delegates to parseMCPResponseFull

All mcp and agent tests pass.

* fix(telegram): fix claude client dispatch + strategy creation workflow

- telegram/bot.go: clientForProvider now returns NewClaudeClient() for
  'claude' provider (was incorrectly falling back to DeepSeekClient which
  uses OpenAI wire format, breaking Anthropic API calls)

- api/server.go: fix scan_interval_minutes schema default (3, not 60);
  POST /api/strategies now clearly states config is OPTIONAL with complete
  working defaults; POST /api/traders removes redundant GET workflow note

- telegram/agent/prompt.go: simplify strategy creation — just POST {name}
  without config (backend applies full working defaults automatically);
  only include config when user requests custom settings

* test(mcp): add ClaudeClient wire format tests

Tests cover all Anthropic-specific format conversions:
- system prompt lifted to top-level field
- tools use input_schema (not parameters)
- tool_choice is object {type:auto} not string
- assistant tool calls → content[{type:tool_use}]
- consecutive tool results merged into single user turn
- parseMCPResponseFull: text, tool_use, and error cases
- x-api-key header (not Authorization: Bearer)
- /messages endpoint URL

* fix(telegram): clientForProvider returns correct client for all 7 providers

Previously qwen/kimi/grok/gemini all fell back to DeepSeekClient.
Each provider now gets its own dedicated client with correct default
base URL and model. All 7 providers now fully supported:
openai, deepseek, claude, qwen, kimi, grok, gemini

* fix(telegram): newLLMClient uses bound user's model, not any user's model

GetAnyEnabled() searched across all users in DB — if user B has an
enabled model, bot could use their API key while acting as user A.

Now uses GetDefault(botUserID) which only looks up the bound user's
enabled model, matching the same user scope as all API calls.

* fix(auth): single-user deployment by default, no open registration

Registration logic redesigned:
- Empty DB (first-time setup): registration always open, no config needed
- After first user exists: registration closed by default
- Multi-user opt-in: set REGISTRATION_ENABLED=true + MAX_USERS=N in .env

Config defaults changed:
- RegistrationEnabled: true → false (closed after first user)
- MaxUsers: 10 → 1 (single-user deployment default)

This eliminates the confusion of multiple users appearing in a personal
deployment where Telegram is bound to a single admin account.

* feat(solo): beginner-friendly onboarding — smart setup guide + direct config commands

start.sh:
- Interactive Telegram Bot Token prompt on first run
- Token format validation (must match 12345:ABC... pattern)
- Friendly step-by-step startup instructions after launch

telegram/bot.go:
- /start now shows context-aware setup guide based on actual config state:
  - No AI model → explains how to configure, lists all providers
  - AI model OK but no exchange → guides to configure exchange via chat
  - All configured → full capabilities welcome message
- New: direct setup commands ('配置 deepseek sk-xxx') bypass LLM entirely
  so AI model can be configured even before any model exists (bootstrap fix)
- All messages now in Chinese (匹配用户语言)

telegram/agent/prompt.go:
- Added first-time setup detection section
- Agent told to never ask user to visit web UI — everything via chat

* feat(i18n): bilingual EN/ZH setup guide with language selection

store/telegram_config.go:
- Add Language field to TelegramConfig (persisted in DB)
- Add SetLanguage(lang) and GetLanguage() methods
- Default language: English (en)

telegram/bot.go:
- First /start triggers language selection (1=English, 2=中文)
- /lang command to change language at any time
- awaitingLang state machine handles language choice before any other input
- buildSetupGuide() now fully bilingual (EN/ZH), context-aware:
  Step 1: configure AI model (no model yet)
  Step 2: configure exchange (model OK, no exchange)
  Ready: show full capabilities
- tryHandleSetupCommand() bilingual: 'configure/配置 <provider> <key>'
- helpMessage(lang) fully bilingual
- All error/status messages bilingual

Default: English. isLangDefault() detects whether user has explicitly
chosen a language vs falling back to the 'en' default.

* fix(telegram): use Markdown rendering + simplify language selection condition

- sendMarkdownMsg() helper: sends with ParseMode=Markdown, falls back to plain text
- All formatted messages (langSelectionMsg, buildSetupGuide, helpMessage) now render
  bold text and code blocks correctly in Telegram
- Simplify /start language check: isLangDefault(st) alone is sufficient
  (lang == 'en' && isLangDefault was redundant — GetLanguage returns 'en' when empty)

* fix(start.sh): translate all user-facing text to English

Entire script was in Chinese. Now English-first throughout:
- startup banner, prompts, success/error messages
- setup_telegram(): English instructions and validation messages
- start(): English next-steps after launch
- stop/restart/clean/update/regenerate-keys/show_help: all English

* fix(telegram): remove 'default' user fallback — resolve user dynamically

- botUserID no longer captured once at startup (was 'default' if no user yet)
- resolveBotUser() reads first registered user from DB on demand:
  * called on every /start (handles: registered after bot launch)
  * called before every AI message (handles mid-session registration)
- If no user registered: clear English error 'No account found. Please register on the web UI first'
- start.sh: fix set_env_var appending without newline (token was concatenated to prev line)

* refactor(telegram): clean onboarding — web UI for setup, Telegram for operations

- /start shows clean status: 'setup required → open web UI' or 'ready → examples'
- Removed tryHandleSetupCommand (no more CLI-style 'configure deepseek sk-xxx')
- Removed automatic language selection on /start (use /lang anytime instead)
- newLLMClient returns nil when no model → clear guard, not fallback
- statusMsg() replaces buildSetupGuide(): two states only (missing config / ready)
- Bot is now purely an operations interface; config lives in the web UI

* refactor: single-user web-based setup — replace env config with Settings UI

Move from multi-user env-var config to single-user web-first architecture:
- Add SetupPage for first-time initialization (replaces /register)
- Add SettingsPage for AI models, exchanges, Telegram, and password management
- Enrich all API route schemas with exact ID usage documentation
- Add PUT /user/password endpoint for in-app password changes
- Remove REGISTRATION_ENABLED, MAX_USERS, TELEGRAM_BOT_TOKEN from env config
- Simplify LoginPage design, remove admin mode and registration links
- Telegram bot now resolves user email for identity display
- start.sh no longer runs interactive Telegram setup

* feat: add blockRun (x402 USDC) support to all AI model consumers

- telegram/bot.go: add blockrun-base, blockrun-sol, minimax to
  clientForProvider; fix newLLMClient to prefer TelegramConfig.ModelID
  over GetDefault; log USDC payment provider usage
- debate/engine.go: add blockrun-base, blockrun-sol to InitializeClients
- api/strategy.go: add blockrun-base, blockrun-sol to runRealAITest
- backtest/ai_client.go: add blockrun-base, blockrun-sol to configureMCPClient

* feat: add Claw402 (claw402.ai) x402 USDC payment provider

Add Claw402Client for claw402.ai's x402 micropayment gateway (Base USDC).
Supports 15+ AI models (GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, etc.)
with per-model endpoint routing.

- mcp/claw402.go: new client with model→endpoint mapping, x402 v2 payment flow
- mcp/blockrun_base.go: extract shared signX402Payment() for reuse
- Register "claw402" provider in all 6 consumer switch statements:
  api/server.go, api/strategy.go, trader/auto_trader.go,
  telegram/bot.go, debate/engine.go, backtest/ai_client.go

* feat: redesign Claw402 model config UI — friendly wallet setup, USDC guide, official logo, nginx no-cache for index.html

* refactor: centralize x402 payment flow into shared mcp/x402.go

Extract duplicated doRequestWithPayment/call/CallWithRequestFull/buildRequest/
setAuthHeader (~165 lines x3) into shared helpers in mcp/x402.go. Consolidate
shared types (x402v2PaymentRequired, x402AcceptOption, x402Resource) and remove
duplicate Solana types. Fix validAfter to 0 (official SDK standard), drain 402
body before retry, log Payment-Response tx hash, check Payment-Required before
X-Payment-Required.

* fix: stop PR template bot from overwriting user-written descriptions

The pr-template-suggester workflow was triggered on opened/edited/synchronize
events and forcefully replaced the PR body with a template when body < 100 chars.
This caused user-written descriptions to be overwritten.

Replace with a lightweight labeler (OpenClaw-style) that:
- Only adds labels (backend/frontend/docs, size: XS/S/M/L/XL)
- Never modifies the PR body
- Simplified unified PR template at .github/pull_request_template.md

* chore: simplify PR template (OpenClaw-style)
2026-03-11 16:01:42 +08:00

34 KiB
Raw Blame History

Telegram Bot Agent Redesign (OpenClaw-Inspired)

For Claude: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.

Goal: Replace the NLU intent-classification architecture with a true AI Agent that handles any user request — including scenarios never explicitly programmed. All code, comments, prompts, and bot responses in English.

Architecture: One generic tool (api_call) + dynamically generated API docs + unbounded LLM loop. The LLM reads auto-generated API docs and decides which endpoints to call. New features added to the web UI automatically become available via bot — zero code changes required.

Tech Stack: Go, mcp.CallWithRequest + RequestBuilder, tgbotapi, auth.GenerateJWT


Core Design

OpenClaw gives LLM a bash tool — one generic primitive, unlimited capability. We give LLM an api_call(method, path, body) tool — one generic primitive for 74+ REST endpoints.

Auto-discovery: Routes are registered via s.route(group, method, path, description, handler). api.GetAPIDocs() returns live documentation at startup — add a route and it's automatically in the bot's context.

User: "show positions and stop the trader if loss > 5%"

Iteration 1: api_call GET /api/positions?trader_id=...
Iteration 2: api_call GET /api/account?trader_id=...
Iteration 3: [sees -8% loss] api_call POST /api/traders/xxx/stop
Reply: "Detected -8% loss. Trader stopped."

No special code for this scenario. LLM figured it out from the API docs.


What changes

File Action
api/route_registry.go CREATE — route registration + doc generation
api/server.go Migrate all routes from group.METHOD(path, handler) to s.route(group, method, path, desc, handler)
telegram/intent/parser.go DELETE
telegram/handler/handler.go DELETE
telegram/handler/handler_test.go DELETE
telegram/session/session.go Simplify (remove Intent, Params)
telegram/bot.go Use agent.Manager, pass api.GetAPIDocs()
telegram/agent/prompt.go CREATE — system prompt template (API docs injected at runtime)
telegram/agent/apicall.go CREATE — the single generic tool
telegram/agent/agent.go CREATE — agent loop
telegram/agent/manager.go CREATE — per-chat serialization
telegram/agent/agent_test.go CREATE — tests

telegram/service/nofx.go and telegram/session/memory.go are unchanged.


Task 1: Create api/route_registry.go

Files:

  • Create: api/route_registry.go

This is the single source of truth for API documentation. Routes registered here are automatically available to the bot.

package api

import (
	"fmt"
	"strings"

	"github.com/gin-gonic/gin"
)

// RouteDoc holds documentation for a single API route.
type RouteDoc struct {
	Method      string
	Path        string
	Description string
}

// routeRegistry stores all documented routes. Populated via s.route() calls in setupRoutes.
var routeRegistry []RouteDoc

// route registers an HTTP route on the given group and records its documentation.
// This is the single registration point — add a route here and it is automatically
// included in GetAPIDocs(), making it available to the Telegram bot agent.
func (s *Server) route(g *gin.RouterGroup, method, path, description string, h gin.HandlerFunc) {
	// Derive the full path: group prefix + local path
	fullPath := strings.TrimSuffix(g.BasePath(), "/") + "/" + strings.TrimPrefix(path, "/")
	routeRegistry = append(routeRegistry, RouteDoc{
		Method:      method,
		Path:        fullPath,
		Description: description,
	})
	switch method {
	case "GET":
		g.GET(path, h)
	case "POST":
		g.POST(path, h)
	case "PUT":
		g.PUT(path, h)
	case "DELETE":
		g.DELETE(path, h)
	}
}

// GetAPIDocs returns formatted API documentation for injection into the LLM system prompt.
// Called once at bot startup — reflects the live set of registered routes.
func GetAPIDocs() string {
	var sb strings.Builder
	for _, r := range routeRegistry {
		sb.WriteString(fmt.Sprintf("%-8s %-50s %s\n", r.Method, r.Path, r.Description))
	}
	return sb.String()
}

Step 1: Create the file

Step 2: Build

cd /Users/yida/gopro/open-nofx && go build ./api/...

Expected: clean build.

Step 3: Commit

git add api/route_registry.go
git commit -m "feat(api): add route registry for auto-generated API documentation"

Task 2: Migrate routes in api/server.go

Files:

  • Modify: api/server.go (the setupRoutes / route registration block, lines ~109230)

Replace every direct group.METHOD(path, handler) call with s.route(group, method, path, description, handler).

Step 1: Read the current route registration block

sed -n '109,230p' api/server.go

Step 2: Replace all route registrations

The full replacement (covers all routes found in lines 117223):

// Public routes
s.route(api, "GET",  "/supported-models",          "List supported AI model providers",           s.handleGetSupportedModels)
s.route(api, "GET",  "/supported-exchanges",        "List supported exchange types",                s.handleGetSupportedExchanges)
s.route(api, "GET",  "/config",                     "Get system configuration",                     s.handleGetSystemConfig)
s.route(api, "GET",  "/traders",                    "Public trader list",                           s.handlePublicTraderList)
s.route(api, "GET",  "/competition",                "Public competition data",                      s.handlePublicCompetition)
s.route(api, "GET",  "/top-traders",                "Top traders leaderboard",                      s.handleTopTraders)
s.route(api, "GET",  "/equity-history",             "Equity history for a trader",                  s.handleEquityHistory)
s.route(api, "POST", "/equity-history-batch",       "Batch equity history for multiple traders",    s.handleEquityHistoryBatch)
s.route(api, "GET",  "/traders/:id/public-config",  "Public trader configuration",                  s.handleGetPublicTraderConfig)
s.route(api, "GET",  "/klines",                     "Candlestick data (?symbol=&interval=&limit=)", s.handleKlines)
s.route(api, "GET",  "/symbols",                    "Available trading symbols",                    s.handleSymbols)
s.route(api, "GET",  "/strategies/public",          "Public strategy market",                       s.handlePublicStrategies)
s.route(api, "POST", "/register",                   "Register new user",                            s.handleRegister)
s.route(api, "POST", "/login",                      "User login, returns JWT token",                s.handleLogin)

// Protected routes (JWT required)
s.route(protected, "POST",   "/logout",                       "Logout (blacklist token)",                        s.handleLogout)
s.route(protected, "GET",    "/server-ip",                    "Get server public IP (for exchange whitelist)",   s.handleGetServerIP)

// Trader management
s.route(protected, "GET",    "/my-traders",                   "List user's traders",                             s.handleTraderList)
s.route(protected, "GET",    "/traders/:id/config",           "Get full trader configuration",                   s.handleGetTraderConfig)
s.route(protected, "POST",   "/traders",                      "Create trader (body: name, strategy_id, exchange_id, model_id)", s.handleCreateTrader)
s.route(protected, "PUT",    "/traders/:id",                  "Update trader configuration",                     s.handleUpdateTrader)
s.route(protected, "DELETE", "/traders/:id",                  "Delete trader",                                   s.handleDeleteTrader)
s.route(protected, "POST",   "/traders/:id/start",            "Start trader",                                    s.handleStartTrader)
s.route(protected, "POST",   "/traders/:id/stop",             "Stop trader",                                     s.handleStopTrader)
s.route(protected, "PUT",    "/traders/:id/prompt",           "Update trader prompt (body: prompt)",             s.handleUpdateTraderPrompt)
s.route(protected, "POST",   "/traders/:id/sync-balance",     "Sync account balance from exchange",              s.handleSyncBalance)
s.route(protected, "POST",   "/traders/:id/close-position",   "Close position (body: symbol)",                   s.handleClosePosition)
s.route(protected, "PUT",    "/traders/:id/competition",      "Toggle competition visibility",                   s.handleToggleCompetition)
s.route(protected, "GET",    "/traders/:id/grid-risk",        "Get grid risk info",                              s.handleGetGridRiskInfo)

// AI model configuration
s.route(protected, "GET", "/models", "List AI model configurations",   s.handleGetModelConfigs)
s.route(protected, "PUT", "/models", "Update AI model configurations", s.handleUpdateModelConfigs)

// Exchange configuration
s.route(protected, "GET",    "/exchanges",     "List exchange configurations",                                      s.handleGetExchangeConfigs)
s.route(protected, "POST",   "/exchanges",     "Create exchange (body: exchange_type, api_key, secret_key, account_name)", s.handleCreateExchange)
s.route(protected, "PUT",    "/exchanges",     "Update exchange configurations",                                    s.handleUpdateExchangeConfigs)
s.route(protected, "DELETE", "/exchanges/:id", "Delete exchange",                                                   s.handleDeleteExchange)

// Telegram configuration
s.route(protected, "GET",    "/telegram",         "Get Telegram bot configuration",       s.handleGetTelegramConfig)
s.route(protected, "POST",   "/telegram",         "Update Telegram bot token/model",      s.handleUpdateTelegramConfig)
s.route(protected, "POST",   "/telegram/model",   "Update Telegram bot AI model only",   s.handleUpdateTelegramModel)
s.route(protected, "DELETE", "/telegram/binding", "Unbind Telegram account",             s.handleUnbindTelegram)

// Strategy management
s.route(protected, "GET",    "/strategies",                  "List user's strategies",                            s.handleGetStrategies)
s.route(protected, "GET",    "/strategies/active",           "Get active strategy",                               s.handleGetActiveStrategy)
s.route(protected, "GET",    "/strategies/default-config",   "Get default strategy config template",             s.handleGetDefaultStrategyConfig)
s.route(protected, "POST",   "/strategies/preview-prompt",   "Preview generated strategy prompt",                s.handlePreviewPrompt)
s.route(protected, "POST",   "/strategies/test-run",         "Test-run strategy AI analysis",                    s.handleStrategyTestRun)
s.route(protected, "GET",    "/strategies/:id",              "Get strategy by ID",                               s.handleGetStrategy)
s.route(protected, "POST",   "/strategies",                  "Create strategy (body: name, config)",              s.handleCreateStrategy)
s.route(protected, "PUT",    "/strategies/:id",              "Update strategy",                                   s.handleUpdateStrategy)
s.route(protected, "DELETE", "/strategies/:id",              "Delete strategy",                                   s.handleDeleteStrategy)
s.route(protected, "POST",   "/strategies/:id/activate",     "Activate strategy",                                s.handleActivateStrategy)
s.route(protected, "POST",   "/strategies/:id/duplicate",    "Duplicate strategy",                               s.handleDuplicateStrategy)

// Debate arena
s.route(protected, "GET",    "/debates",                   "List debates",                    s.debateHandler.HandleListDebates)
s.route(protected, "GET",    "/debates/personalities",     "Available AI personalities",      s.debateHandler.HandleGetPersonalities)
s.route(protected, "GET",    "/debates/:id",               "Get debate details",              s.debateHandler.HandleGetDebate)
s.route(protected, "POST",   "/debates",                   "Create debate",                   s.debateHandler.HandleCreateDebate)
s.route(protected, "POST",   "/debates/:id/start",         "Start debate",                    s.debateHandler.HandleStartDebate)
s.route(protected, "POST",   "/debates/:id/cancel",        "Cancel debate",                   s.debateHandler.HandleCancelDebate)
s.route(protected, "POST",   "/debates/:id/execute",       "Execute debate consensus decision", s.debateHandler.HandleExecuteDebate)
s.route(protected, "DELETE", "/debates/:id",               "Delete debate",                   s.debateHandler.HandleDeleteDebate)
s.route(protected, "GET",    "/debates/:id/messages",      "Get debate messages",             s.debateHandler.HandleGetMessages)
s.route(protected, "GET",    "/debates/:id/votes",         "Get debate votes",                s.debateHandler.HandleGetVotes)
s.route(protected, "GET",    "/debates/:id/stream",        "SSE stream for live debate",      s.debateHandler.HandleDebateStream)

// Account and trading data (use ?trader_id=xxx query param)
s.route(protected, "GET", "/status",             "Trader running status (?trader_id=)",      s.handleStatus)
s.route(protected, "GET", "/account",            "Account balance and equity (?trader_id=)", s.handleAccount)
s.route(protected, "GET", "/positions",          "Current open positions (?trader_id=)",     s.handlePositions)
s.route(protected, "GET", "/positions/history",  "Position history (?trader_id=)",           s.handlePositionHistory)
s.route(protected, "GET", "/trades",             "Trade records (?trader_id=)",              s.handleTrades)
s.route(protected, "GET", "/orders",             "All orders (?trader_id=)",                 s.handleOrders)
s.route(protected, "GET", "/orders/:id/fills",   "Order fill details",                       s.handleOrderFills)
s.route(protected, "GET", "/open-orders",        "Open orders from exchange (?trader_id=)",  s.handleOpenOrders)
s.route(protected, "GET", "/decisions",          "AI trading decisions (?trader_id=)",       s.handleDecisions)
s.route(protected, "GET", "/decisions/latest",   "Latest AI decisions (?trader_id=)",        s.handleLatestDecisions)
s.route(protected, "GET", "/statistics",         "Trading statistics (?trader_id=)",         s.handleStatistics)

Note: keep the existing special-case handlers that don't use s.route unchanged:

  • api.Any("/health", ...) — health check, no need to document
  • api.GET("/crypto/...") — crypto/encryption routes, bot doesn't need these
  • backtest.* routes (registered separately) — add descriptions to the backtest group similarly

Step 3: Build

go build ./api/...

Expected: clean build. Fix any compilation errors (method signature mismatches).

Step 4: Verify docs are generated

go test ./api/... -run TestGetAPIDocs -v

(Write a quick inline test or just print in main to verify)

Step 5: Commit

git add api/route_registry.go api/server.go
git commit -m "feat(api): migrate routes to self-documenting s.route() registration"

Task 3: Create telegram/agent/prompt.go

Files:

  • Create: telegram/agent/prompt.go

The system prompt template. API docs are injected at runtime via BuildAgentPrompt(apiDocs).

package agent

import "fmt"

// BuildAgentPrompt constructs the full system prompt with live API documentation injected.
// apiDocs is the output of api.GetAPIDocs() — reflects all currently registered routes.
func BuildAgentPrompt(apiDocs string) string {
	return fmt.Sprintf(`You are the NOFX quantitative trading system AI assistant.
You can have natural conversations with the user and call the API to operate the system.

## Tool

You have one tool: api_call

Call format (append at end of reply):
<api_call>{"method":"GET","path":"/api/xxx","body":{}}</api_call>

- method: "GET" | "POST" | "PUT" | "DELETE"
- path: API path from the documentation below
- body: request body as JSON object (use {} for GET requests)
- query parameters go in the path, e.g. /api/positions?trader_id=xxx

## NOFX API Documentation

All requests are pre-authenticated. Focus on paths and parameters.

%s

## Rules
1. When you need to perform a system operation, append <api_call>...</api_call> at the end of your reply
2. Only call one API per response; after receiving the result, decide whether to call another or give a final reply
3. For conversations, questions, or analysis that don't require system operations, reply directly without calling the API
4. If required parameters are unclear, ask the user — do not guess critical values like trader_id
5. Always reply in English`, apiDocs)
}

Step 1: Create the file

Step 2: Build

go build ./telegram/agent/...

Step 3: Commit

git add telegram/agent/prompt.go
git commit -m "feat(telegram/agent): add dynamic system prompt builder"

Task 4: Create telegram/agent/apicall.go

Files:

  • Create: telegram/agent/apicall.go
package agent

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"nofx/logger"
	"strings"
	"time"
)

// apiCallTool executes HTTP requests against the NOFX API server.
// This is the only tool available to the agent.
type apiCallTool struct {
	baseURL string
	token   string
	client  *http.Client
}

// apiRequest is the parsed structure from the LLM's <api_call> tag.
type apiRequest struct {
	Method string         `json:"method"`
	Path   string         `json:"path"`
	Body   map[string]any `json:"body"`
}

func newAPICallTool(port int, token string) *apiCallTool {
	return &apiCallTool{
		baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
		token:   token,
		client:  &http.Client{Timeout: 30 * time.Second},
	}
}

// execute calls the API and returns the response as a string for LLM consumption.
func (t *apiCallTool) execute(req *apiRequest) string {
	if req.Method == "" || req.Path == "" {
		return "error: method and path are required"
	}
	if !strings.HasPrefix(req.Path, "/") {
		req.Path = "/" + req.Path
	}

	var bodyReader io.Reader
	if req.Method != "GET" && len(req.Body) > 0 {
		b, err := json.Marshal(req.Body)
		if err != nil {
			return fmt.Sprintf("error marshaling body: %v", err)
		}
		bodyReader = bytes.NewReader(b)
	}

	httpReq, err := http.NewRequest(req.Method, t.baseURL+req.Path, bodyReader)
	if err != nil {
		return fmt.Sprintf("error creating request: %v", err)
	}
	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("Authorization", "Bearer "+t.token)

	resp, err := t.client.Do(httpReq)
	if err != nil {
		return fmt.Sprintf("API call failed: %v", err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return fmt.Sprintf("error reading response: %v", err)
	}

	logger.Infof("Agent api_call: %s %s -> %d", req.Method, req.Path, resp.StatusCode)

	if resp.StatusCode >= 400 {
		return fmt.Sprintf("API error %d: %s", resp.StatusCode, string(body))
	}

	// Pretty-print JSON for better LLM readability
	var v any
	if json.Unmarshal(body, &v) == nil {
		if pretty, err := json.MarshalIndent(v, "", "  "); err == nil {
			return string(pretty)
		}
	}
	return string(body)
}

// parseAPICall extracts <api_call>...</api_call> from LLM response.
// Returns (nil, original) if not found or malformed JSON.
func parseAPICall(resp string) (*apiRequest, string) {
	const openTag = "<api_call>"
	const closeTag = "</api_call>"

	start := strings.Index(resp, openTag)
	end := strings.Index(resp, closeTag)
	if start < 0 || end < 0 || end <= start {
		return nil, resp
	}

	jsonStr := strings.TrimSpace(resp[start+len(openTag) : end])
	var req apiRequest
	if err := json.Unmarshal([]byte(jsonStr), &req); err != nil {
		logger.Warnf("Agent: failed to parse api_call JSON %q: %v", jsonStr, err)
		return nil, resp
	}

	return &req, strings.TrimSpace(resp[:start])
}

Step 1: Create the file

Step 2: Commit

git add telegram/agent/apicall.go
git commit -m "feat(telegram/agent): add generic api_call tool"

Task 5: Create telegram/agent/agent.go

Files:

  • Create: telegram/agent/agent.go
package agent

import (
	"fmt"
	"nofx/auth"
	"nofx/logger"
	"nofx/mcp"
	"nofx/telegram/session"
	"strings"
)

const maxIterations = 10

// Agent is a stateful AI agent for one Telegram chat.
// It has a single tool (api_call) and an unbounded decision loop.
type Agent struct {
	apiTool    *apiCallTool
	getLLM     func() mcp.AIClient
	memory     *session.Memory
	systemPrompt string
}

// New creates an Agent for one chat session.
func New(apiPort int, botToken string, getLLM func() mcp.AIClient, systemPrompt string) *Agent {
	return &Agent{
		apiTool:      newAPICallTool(apiPort, botToken),
		getLLM:       getLLM,
		memory:       session.NewMemory(getLLM()),
		systemPrompt: systemPrompt,
	}
}

// GenerateBotToken creates a long-lived JWT for the bot's internal API calls.
// Call once at bot startup before creating any Agent or Manager.
func GenerateBotToken() (string, error) {
	return auth.GenerateJWT("default", "bot@internal")
}

// Run processes one user message through the agent loop.
// Loop: LLM decides -> if <api_call>: execute, append result, loop -> if no tag: return reply.
func (a *Agent) Run(userMessage string) string {
	llm := a.getLLM()
	if llm == nil {
		return "AI assistant unavailable. Please configure an AI model in the Web UI."
	}

	// Build turn messages: history context prefix + current user message
	histCtx := a.memory.BuildContext()
	firstMsg := userMessage
	if histCtx != "" {
		firstMsg = histCtx + "\n---\nUser: " + userMessage
	}
	turnMsgs := []mcp.Message{mcp.NewUserMessage(firstMsg)}

	var lastResp string

	for i := 0; i < maxIterations; i++ {
		req, err := mcp.NewRequestBuilder().
			WithSystemPrompt(a.systemPrompt).
			AddConversationHistory(turnMsgs).
			Build()
		if err != nil {
			logger.Errorf("Agent: failed to build request: %v", err)
			break
		}

		resp, err := llm.CallWithRequest(req)
		if err != nil {
			logger.Errorf("Agent: LLM call failed (iteration %d): %v", i+1, err)
			return "AI assistant temporarily unavailable. Please try again."
		}
		lastResp = resp

		apiReq, textBefore := parseAPICall(resp)
		if apiReq == nil {
			// No api_call tag — LLM gave a final answer
			reply := strings.TrimSpace(resp)
			a.memory.Add("user", userMessage)
			a.memory.Add("assistant", reply)
			return reply
		}

		logger.Infof("Agent: iter=%d %s %s", i+1, apiReq.Method, apiReq.Path)
		result := a.apiTool.execute(apiReq)

		if textBefore != "" {
			turnMsgs = append(turnMsgs, mcp.NewAssistantMessage(textBefore))
		}
		turnMsgs = append(turnMsgs, mcp.NewUserMessage(
			fmt.Sprintf("[API result: %s %s]\n%s", apiReq.Method, apiReq.Path, result),
		))
	}

	// Safety: max iterations reached — ask LLM for a final summary
	logger.Warnf("Agent: max iterations (%d) reached", maxIterations)
	turnMsgs = append(turnMsgs, mcp.NewUserMessage("Please summarize the results and give the user a final reply."))
	if finalReq, err := mcp.NewRequestBuilder().
		WithSystemPrompt(a.systemPrompt).
		AddConversationHistory(turnMsgs).
		Build(); err == nil {
		if finalResp, err := llm.CallWithRequest(finalReq); err == nil {
			lastResp = finalResp
		}
	}

	reply := strings.TrimSpace(lastResp)
	a.memory.Add("user", userMessage)
	a.memory.Add("assistant", reply)
	return reply
}

// ResetMemory clears conversation history (called on /start).
func (a *Agent) ResetMemory() {
	a.memory.ResetFull()
}

Step 1: Create the file

Step 2: Build

go build ./telegram/agent/...

Step 3: Commit

git add telegram/agent/agent.go
git commit -m "feat(telegram/agent): add OpenClaw-style agent loop"

Task 6: Create telegram/agent/manager.go

Files:

  • Create: telegram/agent/manager.go
package agent

import (
	"nofx/mcp"
	"sync"
)

// Manager holds one Agent per Telegram chat ID.
// Messages for the same chat are serialized (OpenClaw Lane Queue pattern).
type Manager struct {
	mu           sync.Mutex
	agents       map[int64]*Agent
	lanes        map[int64]chan struct{}
	apiPort      int
	botToken     string
	getLLM       func() mcp.AIClient
	systemPrompt string
}

// NewManager creates a Manager. Call api.GetAPIDocs() before this and pass the result as apiDocs.
func NewManager(apiPort int, botToken string, getLLM func() mcp.AIClient, apiDocs string) *Manager {
	return &Manager{
		agents:       make(map[int64]*Agent),
		lanes:        make(map[int64]chan struct{}),
		apiPort:      apiPort,
		botToken:     botToken,
		getLLM:       getLLM,
		systemPrompt: BuildAgentPrompt(apiDocs),
	}
}

// Run processes a message for the given chat ID.
// If the same chat is already processing a message, this call blocks until it completes.
func (m *Manager) Run(chatID int64, userMessage string) string {
	a, lane := m.getOrCreate(chatID)
	lane <- struct{}{}
	defer func() { <-lane }()
	return a.Run(userMessage)
}

// Reset clears memory for the given chat (called on /start).
func (m *Manager) Reset(chatID int64) {
	m.mu.Lock()
	a, ok := m.agents[chatID]
	m.mu.Unlock()
	if ok {
		a.ResetMemory()
	}
}

func (m *Manager) getOrCreate(chatID int64) (*Agent, chan struct{}) {
	m.mu.Lock()
	defer m.mu.Unlock()

	a, ok := m.agents[chatID]
	if !ok {
		a = New(m.apiPort, m.botToken, m.getLLM, m.systemPrompt)
		m.agents[chatID] = a
	}
	lane, ok := m.lanes[chatID]
	if !ok {
		lane = make(chan struct{}, 1) // binary semaphore: one message at a time per chat
		m.lanes[chatID] = lane
	}
	return a, lane
}

Step 1: Create the file

Step 2: Build

go build ./telegram/agent/...

Step 3: Commit

git add telegram/agent/manager.go
git commit -m "feat(telegram/agent): add per-chat agent manager with lane serialization"

Task 7: Write tests

Files:

  • Create: telegram/agent/agent_test.go
package agent

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"nofx/mcp"
)

type mockLLM struct {
	responses []string
	calls     int
	lastMsgs  []mcp.Message
}

func (m *mockLLM) SetAPIKey(_, _, _ string)       {}
func (m *mockLLM) SetTimeout(_ time.Duration)      {}
func (m *mockLLM) CallWithMessages(_, _ string) (string, error) { return m.next() }
func (m *mockLLM) CallWithRequest(req *mcp.Request) (string, error) {
	m.lastMsgs = req.Messages
	return m.next()
}
func (m *mockLLM) next() (string, error) {
	if m.calls < len(m.responses) {
		r := m.responses[m.calls]
		m.calls++
		return r, nil
	}
	return "OK", nil
}

func mockGetLLM(llm *mockLLM) func() mcp.AIClient {
	return func() mcp.AIClient { return llm }
}

const testPrompt = "You are a test assistant."

// TestAgentDirectReply: LLM replies without api_call — one call, direct reply.
func TestAgentDirectReply(t *testing.T) {
	llm := &mockLLM{responses: []string{"Hello! How can I help you?"}}
	a := New(8080, "tok", mockGetLLM(llm), testPrompt)

	reply := a.Run("hello")

	if reply != "Hello! How can I help you?" {
		t.Fatalf("unexpected reply: %q", reply)
	}
	if llm.calls != 1 {
		t.Fatalf("expected 1 LLM call, got %d", llm.calls)
	}
}

// TestAgentAPICall: LLM calls API, gets result, gives final reply — two LLM calls.
func TestAgentAPICall(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/api/my-traders" {
			w.Write([]byte(`[{"id":"t1","name":"BTC Strategy"}]`))
			return
		}
		w.WriteHeader(404)
	}))
	defer srv.Close()

	var port int
	fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)

	llm := &mockLLM{responses: []string{
		`Let me check.<api_call>{"method":"GET","path":"/api/my-traders","body":{}}</api_call>`,
		"You have one trader: BTC Strategy.",
	}}
	a := New(port, "tok", mockGetLLM(llm), testPrompt)

	reply := a.Run("list my traders")

	if reply != "You have one trader: BTC Strategy." {
		t.Fatalf("unexpected reply: %q", reply)
	}
	if llm.calls != 2 {
		t.Fatalf("expected 2 LLM calls, got %d", llm.calls)
	}
}

// TestAgentMultiStep: LLM chains two API calls before final reply — three LLM calls.
func TestAgentMultiStep(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`{"ok":true}`))
	}))
	defer srv.Close()

	var port int
	fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)

	llm := &mockLLM{responses: []string{
		`Checking account.<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`,
		`Now checking positions.<api_call>{"method":"GET","path":"/api/positions","body":{}}</api_call>`,
		"Account looks healthy and no open positions.",
	}}
	a := New(port, "tok", mockGetLLM(llm), testPrompt)

	reply := a.Run("show me account status")

	if llm.calls != 3 {
		t.Fatalf("expected 3 LLM calls (2 api + 1 final), got %d", llm.calls)
	}
	if reply != "Account looks healthy and no open positions." {
		t.Fatalf("unexpected final reply: %q", reply)
	}
}

// TestAgentAPIResultInContext: API result must appear in next LLM message.
func TestAgentAPIResultInContext(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`{"balance":1234.56}`))
	}))
	defer srv.Close()

	var port int
	fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)

	llm := &mockLLM{responses: []string{
		`<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`,
		"Balance is 1234.56 USDT.",
	}}
	a := New(port, "tok", mockGetLLM(llm), testPrompt)
	a.Run("show balance")

	found := false
	for _, msg := range llm.lastMsgs {
		if strings.Contains(msg.Content, "API result") || strings.Contains(msg.Content, "balance") {
			found = true
			break
		}
	}
	if !found {
		t.Fatalf("API result not found in subsequent LLM context")
	}
}

// TestParseAPICall: unit tests for the XML tag parser.
func TestParseAPICall(t *testing.T) {
	t.Run("valid call", func(t *testing.T) {
		resp := `Stopping trader.<api_call>{"method":"POST","path":"/api/traders/t1/stop","body":{}}</api_call>`
		req, text := parseAPICall(resp)
		if req == nil {
			t.Fatal("expected api_call, got nil")
		}
		if req.Method != "POST" || req.Path != "/api/traders/t1/stop" {
			t.Fatalf("unexpected req: %+v", req)
		}
		if text != "Stopping trader." {
			t.Fatalf("unexpected text before tag: %q", text)
		}
	})

	t.Run("no call tag", func(t *testing.T) {
		req, text := parseAPICall("Just a reply.")
		if req != nil {
			t.Fatal("expected nil api_call")
		}
		if text != "Just a reply." {
			t.Fatalf("expected original text, got %q", text)
		}
	})

	t.Run("malformed JSON", func(t *testing.T) {
		req, _ := parseAPICall(`<api_call>NOT JSON</api_call>`)
		if req != nil {
			t.Fatal("expected nil for malformed JSON")
		}
	})
}

Step 1: Create the test file

Step 2: Run tests

go test ./telegram/agent/... -v

Expected: all PASS.

Step 3: Commit

git add telegram/agent/agent_test.go
git commit -m "test(telegram/agent): add agent tests with mock HTTP server"

Task 8: Simplify telegram/session/session.go

Replace file content:

package session

import (
	"nofx/mcp"
	"sync"
	"time"
)

// Session holds conversation memory for a single Telegram chat.
type Session struct {
	ChatID    int64
	Memory    *Memory
	UpdatedAt time.Time
}

func (s *Session) ResetFull() { s.Memory.ResetFull() }

// Manager manages sessions by chat ID.
type Manager struct {
	mu       sync.RWMutex
	sessions map[int64]*Session
	llm      mcp.AIClient
}

func NewManager(llm mcp.AIClient) *Manager {
	return &Manager{sessions: make(map[int64]*Session), llm: llm}
}

func (m *Manager) Get(chatID int64) *Session {
	m.mu.Lock()
	defer m.mu.Unlock()
	s, ok := m.sessions[chatID]
	if !ok {
		s = &Session{ChatID: chatID, Memory: NewMemory(m.llm), UpdatedAt: time.Now()}
		m.sessions[chatID] = s
	}
	s.UpdatedAt = time.Now()
	return s
}
go build ./...
git add telegram/session/session.go
git commit -m "refactor(telegram/session): remove intent/params fields"

Task 9: Wire telegram/bot.go

Step 1: In runBot, replace old wiring with:

botToken, err := agent.GenerateBotToken()
if err != nil {
    logger.Errorf("Failed to generate bot JWT: %v", err)
    return false
}
agents := agent.NewManager(cfg.APIServerPort, botToken,
    func() mcp.AIClient { return newLLMClient(st) },
    api.GetAPIDocs(),
)

Step 2: Replace /start reset:

// old: sessions.Get(chatID).ResetFull()
agents.Reset(chatID)

Step 3: Replace message processing:

go func(chatID int64, text string) {
    bot.Send(tgbotapi.NewChatAction(chatID, tgbotapi.ChatTyping)) //nolint:errcheck
    reply := agents.Run(chatID, text)
    msg := tgbotapi.NewMessage(chatID, reply)
    msg.ParseMode = "Markdown"
    if _, err := bot.Send(msg); err != nil {
        msg.ParseMode = ""
        bot.Send(msg) //nolint:errcheck
    }
}(chatID, text)

Step 4: Update imports — remove service, handler, intent, session; add agent, api:

import (
    "nofx/config"
    "nofx/logger"
    "nofx/manager"
    "nofx/mcp"
    "nofx/store"
    "nofx/api"
    "nofx/telegram/agent"
    "os"
    tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)

Step 5: Full build

go build ./...
git add telegram/bot.go
git commit -m "feat(telegram): wire agent.Manager with auto-generated API docs"

Task 10: Delete old files

git rm telegram/intent/parser.go telegram/handler/handler.go telegram/handler/handler_test.go
rmdir telegram/intent telegram/handler 2>/dev/null || true
go build ./... && go test ./...
git commit -m "refactor(telegram): delete old intent/handler packages"

Task 11: End-to-end verification

go test ./telegram/... ./api/... -v -count=1
go build ./...

Manual verification — none of these scenarios need any special code:

  • "hello" → natural conversation reply
  • "list my traders" → GET /api/my-traders, formatted reply
  • "show positions" → GET /api/positions
  • "check balance then stop trader if loss > 5%" → multi-step: GET /api/account → POST /api/traders/:id/stop
  • "create a BTC strategy with 5% stop loss" → GET /api/strategies/default-config → POST /api/strategies
  • "show latest trading decisions" → GET /api/decisions/latest
  • "what's the BTC 1h chart looking like" → GET /api/klines?symbol=BTCUSDT&interval=1h
  • "delete trader xxx" → DELETE /api/traders/:id
  • Any unrecognized input → LLM replies naturally, no error