An end-to-end sample on GreenNode AgentBase: Agent Runtime (LangGraph) + Memory (2 strategies) + MCP Governance (MCP Gateway + Policy Group) + LLM AIP. Runs locally out of the box and deploys straight to your own AgentBase account.
Interactive architecture diagram · The Vietnamese-language guide is available in the repo history.
Endpoint lives on the demo account — it may be taken down after the demo period; deploy your own with Step 5 below.
You say (user alice, session 1) |
What the agent does (automatically) |
|---|---|
| "I love the beach, I'm vegetarian, budget ~5M VND, going to Da Nang in June" | Tavily search through the MCP Gateway → itinerary + weather • remember → stores your preferences • the Memory engine auto-extracts records (CUSTOM + SEMANTIC) |
| Return in a different session (days later): "Where should I go next?" | The UI shows an "Agent just recalled" callout • the reply matches your earlier preferences — no need to repeat yourself |
The 3-column dark UI: Users/Sessions · Chat (markdown + memory callout, token-by-token streaming) · Memory (records grouped per strategy, auto-refresh).
MCP flow: Agent → MCP Gateway (Inbound Auth) → Policy Group → MCP Connector (Outbound Auth) → MCP server. LLM calls are a separate path (direct to LLM AIP here; on AgentBase Runtime they can also go through the Sidecar LLM Proxy — see LLM endpoint).
- Runtime —
src/backend: theGreenNodeAgentBaseAppSDK, agent = langchaincreate_agent+ middleware (see Agent loop) + MCP tools + 2 memory tools; theX-GreenNode-AgentBase-User-Id(→ memoryactorId) /-Session-Id(→thread_id) headers partition memory per user. Both are required on every memory path — missing →400(no default user/session, to avoid mixing data between users). - Memory — one memory, 2 long-term strategies:
user-preferences(CUSTOM, dedicated extraction prompt) +trip-facts(SEMANTIC). CheckpointerAgentBaseMemoryEventsstores conversations; namespace/strategies/<id>/actors/<userId>. Therecalltool searches both strategies (top 5 each, minimum score 0.3, merged and de-duplicated);rememberwrites touser-preferences. - MCP Gateway (module MCP Governance) —
sample-mcp-gw, Inbound Auth = IAM Permissions (alternatives: JWT (default) / No authorization),tavilyMCP Connector (endpoint URL + Outbound Auth = API Key 2LO). Policy Groupsample-gw-policy(first match wins): only travel-buddy may call the 5 Tavily actions — atools/callmatching no rule gets 403 (verified: unknown token → "Request denied by policy."). Note: with no Policy Group attached, everytools/callis 403;tools/listbypasses policy. - Frontend —
src/frontend: vanilla SPA, served by the backend atGET /(same-origin, no CORS).
- Agent Runtime and MCP Gateway are managed by GreenNode on the AgentBase Platform — never inside your VPC. In Public mode they are reached through AgentBase's shared public endpoint; in Private mode they run in the AgentBase VPC (
172.30.0.0/16) and connect privately to your VPC (select VPC + Subnet + Route CIDRs). Images are pulled from Container Registry (vCR), or from a public registry if you agree to that. - LLM, Memory, Access Control are GreenNode platform services. The LLM is an OpenAI-compatible endpoint: this sample calls it directly via
LLM_BASE_URL, and on Agent Runtime you can route it through the Sidecar LLM Proxy instead (see LLM endpoint). Memory and Access Control are reached through the SDK using an IAM service account that the runtime injects automatically. - MCP Gateway = Inbound Auth (IAM Permissions / JWT) → Policy Group → MCP Connector (URL + Outbound Auth). One gateway holds every connector the agent needs (MCP servers on the Internet, on Agent Runtime, in your VPC and on-premises), so an agent talks to one gateway with one endpoint, one Policy Group and one audit trail. Use Public mode when every connector has a public endpoint (this demo); switch to Private mode as soon as one connector lives in your VPC or on-premises (add Route CIDRs for on-premises). The docs do not say whether a Private gateway can also reach the Internet; see One gateway per agent for how to keep Internet connectors on the same gateway if it cannot.
Details for each use case (MCP in a cloud VPC, MCP on-premises) and how to connect on-premises to your VPC: docs/network/README.md.
| This demo | UC A · MCP in a cloud VPC | UC B · MCP on-premises | |
|---|---|---|---|
| Runtime | Public · image on vCR | Private (called by an internal app) | Private (called by an internal app) |
| MCP Gateway (one per agent) | Public | Private | Private + on-premises Route CIDRs |
| Connectors → MCP | tavily → Internet |
crm, inventory → vServer / VKS · tavily stays on the same gateway |
erp, hr → data center via VPN / Interconnect · tavily stays on the same gateway |
├── src/backend/ # main.py (routes) · agent.py (agent loop) · memory_tools.py · mcp_client.py · requirements.txt
├── src/frontend/ # index.html · style.css · app.js (no build step)
├── tests/ # pytest: fake LLM, fake Memory API, mocked MCP gateway (no network)
├── docs/
│ ├── architecture.html # interactive diagram (archify)
│ └── network/ # AWS-style network diagrams (SVG) + README · build_diagrams.py
├── Dockerfile · .env.example
src/backend/agent.py builds the agent with langchain 1.x middleware, so a misbehaving model or tool cannot hang or break a turn:
| Concern | How |
|---|---|
| Runaway loops | ModelCallLimitMiddleware(run_limit=10) ends the turn with a friendly Vietnamese apology; ToolCallLimitMiddleware(run_limit=8) refuses further tool calls with an error the model reads, so it still writes an answer |
| Failing tools | ToolErrorMiddleware: an exception in any tool (MCP call, remember, recall) becomes an error message for the model (exception type only, no trace) instead of killing the turn |
| Transient LLM errors | ModelRetryMiddleware(max_retries=2) on connection errors, 429 and 5xx. It is the only retry layer (the OpenAI client runs with max_retries=0), so a dead LLM costs at most 3 attempts of 60 s |
| Context size | SummarizationMiddleware: above ~16,000 tokens the older messages are replaced by a summary and the last 12 are kept; AI tool calls are never separated from their tool results |
| Current date | a dynamic prompt writes "now" in Asia/Ho_Chi_Minh into the system prompt on every model call (the agent object is cached for the life of the process) |
| Tool output size | MCP tool results are capped at 8,000 characters with a ...[truncated N chars] marker |
| Checkpoint traffic | durability="exit": one checkpoint write per turn instead of one per graph step |
| MCP tools | loaded from the gateway on first use and refreshed every 10 minutes; a failed or empty tools/list is retried on the next turn (/ready shows recovery); the agent is rebuilt when the tool list changes; one malformed tool schema skips only that tool |
memories_used (the "Agent just recalled" callout) lists what remember / recall returned during the current turn only.
Requires Python 3.12 (or Docker) and the GreenNode resources from Steps 1-3 below (LLM key, Memory, MCP Gateway connector). Outside AgentBase Runtime also set GREENNODE_CLIENT_ID / GREENNODE_CLIENT_SECRET (a service account that may call Memory and the gateway).
cp .env.example .env # fill values — see the Env reference belowWithout Docker (the backend loads <repo>/.env itself; only that file is read, never a .env from a parent directory):
python3.12 -m venv .venv && source .venv/bin/activate
pip install -r src/backend/requirements.txt
python src/backend/main.py
# open http://localhost:8080With Docker:
docker build -t travel-buddy . && docker run -p 8080:8080 --env-file .env travel-buddyRun the tests (no network or credentials needed): pip install pytest ruff && pytest -q.
Portal: https://aiplatform.console.vngcloud.vn → AI Platform / AgentBase. Menu names may differ slightly per console version; each step also lists the equivalent API.
- Portal → LLM / Model Access (or API Keys) → Create API Key → copy the
lap-…key. - API:
POST /llm/api/v1/api-keys(seeGET /llm/api/v1/modelsto pick a model, e.g.z-ai/glm-5.3-flash). - The code calls the LLM at
LLM_BASE_URL(default: LLM AIP). See LLM endpoint if you want to use the Sidecar LLM Proxy on the Runtime.
- Portal → Memory → Create Memory → name it
travel-buddy-memory. - Add 2 long-term strategies:
user-preferences— type CUSTOM, paste this extraction prompt: "Extract the user's travel preferences: style (beach/mountain...), food (vegetarian/spicy/street food), budget, planned destination and dates. One Vietnamese sentence per record."trip-facts— type SEMANTIC, auto-generate ON, 30-day expiry.
- Copy the Memory ID (
memory-…) and both Strategy IDs (ltms-…). - API:
POST /memory/memorieswithlongTermMemoryStrategies: [{name, type: "CUSTOM"|"SEMANTIC", customFactExtractionPrompt?, autoGenerate: true, expiryDays: 30}].
- Portal → MCP Governance → MCP Gateway (a.k.a. Resource Gateway) → Create Gateway: name
sample-mcp-gw, Inbound Auth = IAM Permissions, public endpoint, smallest flavor. - Once the gateway is ACTIVE → Add Custom Connector (the Connect button): fill the modal with
- Name:
tavily - Type:
MCP - Endpoint / Connect URL:
https://mcp.tavily.com/mcp/ - Outbound Auth: API Key (
APIKEY) → flow 2LO, create providertavily-apikey, API key = your Tavily key (tavily.com), headerAuthorization, prefixBearer
- Name:
- Copy the Gateway URL (
https://gw-<name>-<id>.agentbase-gateway.…vn) → the agent's URL is<gateway>/tavily. - API:
POST /gateway/api/v1/gateways→PATCH /gateway/api/v1/gateways/sample-mcp-gwwithtargets:[{name:"tavily",type:"MCP",endpoint:"https://mcp.tavily.com/mcp/",outboundAuth:{type:"APIKEY",flow:"2LO",providerName:"tavily-apikey",headerName:"Authorization",headerValuePrefix:"Bearer "}}].
Evaluation order: Inbound Auth → Policy Group (first match wins; no rule matches → 403; no Policy Group attached → all
tools/callare 403;tools/listbypasses policy) → Connector Outbound Auth → MCP server.
- Deploy the agent first (Step 5) with
DEBUG_OPS=1, then callPOST /invocations {"op":"whoami"}on the runtime endpoint (add-H "X-API-Key: …"ifAGENT_API_KEYis set) to get the agent'stoken_sub. SetDEBUG_OPSback to0afterwards. - Portal → MCP Governance → Policy Group → Create Policy Group
sample-gw-policy→ add a policy:allow-travel-tavily— effect allow · principaliam:<runtime-token_sub>· actionstavily__tavily_search,tavily__tavily_extract,tavily__tavily_crawl,tavily__tavily_map,tavily__tavily_research· resourcesgateway:sample-mcp-gw
- Back on the Gateway, attach the policy group. From then on any caller no rule allows → 403 "Request denied by policy."
- API:
POST /policy/api/v1/policy-groups→POST /policy/api/v1/policy-groups/<gid>/policies→PATCH /gateway/api/v1/gateways/sample-mcp-gw {"policyGroupId":"…"}.
- Build & push the image:
docker build -t <registry>/travel-buddy:v1 . && docker push …(your project's vCR registry;docker loginper the Portal → Container Registry instructions). - Portal → AgentBase / Agents → Create Agent (Custom): name
travel-buddy, the image above, flavorruntime-s2-general-2x4, and environment variables per the table below (noGREENNODE_CLIENT_ID/SECRETneeded — the runtime auto-injectsGREENNODE_CLIENT_ID,GREENNODE_CLIENT_SECRETandGREENNODE_AGENT_IDENTITY). - Security Settings of the runtime: set IP Access Control (allowed source CIDRs) and Inbound Identity (IAM Permissions / JWT) — see Production hardening below.
- Open the endpoint URL → the chat UI appears immediately (
GET /). - CLI alternative:
runtime.sh create --name travel-buddy --image … --flavor runtime-s2-general-2x4 --from-cr --env-file .env.deploy. - Set
AGENT_API_KEY(and Runtime Security Settings) before exposing the endpoint: see Production hardening.
| Variable | Required | Meaning |
|---|---|---|
LLM_API_KEY |
Yes | LLM AIP key (lap-…) |
LLM_BASE_URL |
optional | OpenAI-compatible endpoint, default LLM AIP https://maas-llm-aiplatform-hcm.api.vngcloud.vn/v1. On AgentBase Runtime may point to the Sidecar LLM Proxy http://localhost:18080 (verify with GreenNode) |
LLM_MODEL |
optional | default z-ai/glm-5.3-flash |
AGENTBASE_MEMORY_ID |
Yes | memory-… created in Step 2 |
MEMORY_STRATEGY_PREF_ID |
Yes | the user-preferences strategy (CUSTOM): remember writes here, recall searches it |
MEMORY_STRATEGY_FACTS_ID |
Yes | the trip-facts strategy (SEMANTIC): recall searches it too |
MCP_TAVILY_URL |
Yes | <gateway-url>/tavily |
GREENNODE_CLIENT_ID/SECRET |
local only | only for runs outside AgentBase Runtime (the runtime auto-injects them, plus GREENNODE_AGENT_IDENTITY) |
AGENT_API_KEY |
required for any non-local deployment | when set, /invocations, /a2a, /ready and /api/* require the X-API-Key header; the bundled UI asks for the key once and keeps it in sessionStorage. Memory is partitioned by a user id the client chooses, so without a key anyone who reaches the endpoint can read any user's memory and spend your LLM credits |
DEBUG_OPS |
default 0 |
1 enables the {"op":"whoami"} identity op — only while setting up policies |
A2A_PUBLIC_URL |
optional | public URL of this runtime, written into the A2A agent card (default: the relative /a2a) |
LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_HOST |
optional | set all three to trace every turn with Langfuse |
The values in .env.example that start with change-me are rejected at startup, so a forgotten placeholder fails fast instead of failing later with an obscure 401.
By default the agent calls the LLM directly: ChatOpenAI(base_url=LLM_BASE_URL, api_key=LLM_API_KEY) with LLM_BASE_URL=https://maas-llm-aiplatform-hcm.api.vngcloud.vn/v1. This sample does not change that default.
Per the AgentBase docs, LLM calls on a Runtime go through a Sidecar LLM Proxy that is auto-injected when the agent is created (endpoint localhost:18080 in the agent config) — a path separate from the MCP Gateway. To try it on a Runtime, set LLM_BASE_URL=http://localhost:18080. Verify with GreenNode for your runtime version (auth requirements and model names are not covered here) before relying on it.
When AGENT_API_KEY is set, every endpoint except the UI, /health, /api/info and the A2A agent card needs the X-API-Key header (401 otherwise). Errors never include exception text: the response carries a generic message and a request_id that matches the server log.
| Method | Path | Description |
|---|---|---|
| POST | /invocations |
body {"message":"…"} (1-4,000 characters; input is accepted as an alias) + headers X-GreenNode-AgentBase-User-Id, -Session-Id (both required, 1-128 characters of letters, digits and . _ : @ + = ~ -; missing or invalid → 400, no defaults; the Runtime sets them on real traffic) → {"response", "memories_used":[…]}; agent failure → 500. {"op":"whoami"} → the runtime's identity (needs DEBUG_OPS=1, else 403) |
| POST | /api/chat/stream |
same body/headers → SSE token stream (`{"type":"token" |
| GET | /api/memory?actor=<user> |
records grouped by the 2 strategies |
| GET | /api/history?actor=&session=[&limit=50] |
the last conversation messages (events of type conversational; checkpoint blobs are skipped) |
| GET | /api/actors |
users and their sessions |
| GET | /api/info · /health |
config (resource ids only with a valid key when one is required) · liveness. Both public |
| GET | /ready |
deep readiness: memory + gateway tools + LLM configuration (200 ok / 503 degraded). The LLM check is configuration only, so probes cost no tokens |
- Gateway
sample-mcp-gw+tavilyconnector ACTIVE · Policy Group denies unmatched calls (403) (unknown token → "Request denied by policy.") - Two users (
alice— beach/vegetarian/5M;ba— mountain/street food/4M) → two different plans, the memory panel shows records per strategy. - Quick check after your own deploy:
curl -X POST "<runtime-endpoint>/invocations" \ -H "Content-Type: application/json" \ -H "X-GreenNode-AgentBase-User-Id: alice" -H "X-GreenNode-AgentBase-Session-Id: s1" \ -d '{"message":"I love the beach, I am vegetarian, budget 5M VND, going to Da Nang in June"}'
This agent is an A2A server — other agents can discover and call it using the A2A standard (no dedicated SDK required):
| Endpoint | Method | Description |
|---|---|---|
/.well-known/agent-card.json |
GET | Agent card: name, skills (travel-planning, personalization), capabilities (streaming: yes), URL |
/a2a |
POST | JSON-RPC 2.0 message/send → returns a standard A2A Message (contextId + text parts) |
/a2a |
POST | message/stream → SSE: status-update (working) → artifact-update per token (append) → final artifact-update (lastChunk, the complete reply) → status-update (completed, final: true; a failure ends with state failed) |
- The agent card is public (discovery).
POST /a2aneedsX-API-KeywhenAGENT_API_KEYis set. Malformed requests get HTTP400with a JSON-RPC error (-32700parse,-32600invalid request,-32601unknown method,-32602invalid params); agent failures get500with-32603. POST /a2arequires theX-GreenNode-AgentBase-User-Idheader (→ memoryactorId; missing → 400, there is no shared default actor). Through AgentBase Runtime this header is attached automatically; when calling directly, send it yourself (-H 'X-GreenNode-AgentBase-User-Id: alice').- The A2A
contextIdmaps directly tothread_id(ifcontextIdis missing, theX-GreenNode-AgentBase-Session-Idheader is used, and only then a newly generated id), so A2A conversations have memory just like regular chat. - Quick test (the sample message is Vietnamese: "Which area should I stay in for a 3-day trip to Da Lat?"):
curl -s $ENDPOINT/.well-known/agent-card.json | jq '.name, .skills[].id' curl -s -X POST $ENDPOINT/a2a -H 'Content-Type: application/json' -H 'X-GreenNode-AgentBase-User-Id: alice' -d \ '{"jsonrpc":"2.0","id":"1","method":"message/send","params":{"message":{"kind":"message","messageId":"m1","role":"user","parts":[{"kind":"text","text":"Đi Đà Lạt 3 ngày nên ở khu nào?"}]}}}' | jq -r '.result.parts[0].text'
- Tests:
tests/test_http.py(card shape, payload validation, send / stream envelopes).
Every turn (/invocations, /api/chat/stream, and A2A send + stream) is traced with the LangFuse SDK v4 (langfuse>=4.0,<5):
- Pattern:
_traced()enters_lf_scope()(propagate_attributes) around the turn, so the trace name / user / session / tags apply to the root and every child observation (including cost-bearing generations); the OTel CallbackHandler from_lf_callback()is created inside that scope. - Enable it with just 3 env vars:
LANGFUSE_PUBLIC_KEY,LANGFUSE_SECRET_KEY,LANGFUSE_HOST. If any are missing, tracing is disabled automatically and the agent runs normally (nullcontext). - In the LangFuse UI you will see the model and token usage for each generation, tool calls (
tavily_search,recall), the LangGraph tree, and session/user/tags for filtering.
The sample ships with these guards — flip them on when deploying publicly:
| Guard | How |
|---|---|
| Runtime Security Settings | in the Portal runtime's Security Settings: IP Access Control (allowed source CIDRs) + Inbound Identity (IAM Permissions or JWT). Don't use No authorization in production |
| Memory headers validated | X-GreenNode-AgentBase-User-Id / -Session-Id are required on /invocations, /api/chat/stream, /a2a → 400 if missing or malformed (no silent defaults → no cross-user memory mixing) |
| API key on the endpoint | set AGENT_API_KEY=<random> in the runtime env → X-API-Key required on /invocations, /a2a, /ready and /api/* (compared in constant time). Required for any non-local deployment: the user id is chosen by the client, so without a key anyone can read any user's memory and spend your LLM credits. The bundled UI prompts for the key once |
| Input limits | messages must be non-empty and at most 4,000 characters; A2A payloads are shape-checked; user, session and context ids are restricted to a safe character set |
| No error details | failures return a generic message plus a request id; the exception is only in the server log |
| Hide runtime identity | keep DEBUG_OPS=0 (default) — whoami is disabled after policy setup |
| Policy on the gateway | already enforced: only this runtime's principal may call tavily__* (first match wins; no match → 403; no Policy Group attached → all tools/call 403) |
| Bounded turns | at most 10 model calls and 8 tool calls per turn, context summarised above ~16,000 tokens, tool output capped at 8,000 characters (see Agent loop) |
| Transient failures | gateway calls retry with backoff on connection errors (and on 5xx for the idempotent tools/list); LLM calls retry on connection errors, 429 and 5xx; memory writes are never retried after a timeout (no duplicate records); a failing tool becomes an error message for the model |
| Timezone | "today" in the system prompt uses Asia/Ho_Chi_Minh, not container UTC, and is refreshed on every model call |
| Container | runs as a non-root user (uid 10001) with a HEALTHCHECK on /health |
| Readiness probe | GET /ready checks memory + gateway tools + LLM configuration — wire it to your monitor (send the X-API-Key header) |
- Runtimes run on the real wallet (~1 replica × 2x4). Delete: Portal → Agents → Delete; CLI
runtime.sh delete <runtime-id>. Full cleanup: theagentbase-teardownskill.