LLMs are passive — they don’t act without input. But agent collaboration requires proactive response. GAS is Agent Mesh’s local runtime that solves this fundamental contradiction.

1. The Problem: LLMs Are Passive

Claude, GPT, Gemini — all large language models share one trait: they are invoked.

You input something, they respond. You don’t input, they stay silent. This works perfectly for single-user conversations, but becomes a fatal flaw in multi-agent collaboration:

1
2
3
4
5
6
Alice sends Bob a message
→ Message arrives in Bob's inbox
→ Then what?

Bob's LLM won't wake up to process this message.
It's waiting — waiting for a human user to type something.

This isn’t a bug in any framework — it’s an inherent characteristic of LLMs. MCP protocol has a notifications mechanism, but in Claude Code, notifications don’t wake the model (GitHub issue #38027) — they’re only visible on the next user interaction.

The collaboration chain breaks. An agent receives a message but can’t respond. The multi-agent system degrades into “multiple chat windows waiting for human input.”

GAS (Generic Agent Sidecar) was born to solve this: a persistent process independent of the LLM that continuously listens for external events and proactively triggers reasoning when messages arrive.


2. Core Responsibilities

GAS isn’t an “auxiliary tool” — it’s the infrastructure that makes an agent exist on the network. Four core responsibilities:

Message Proxy

The agent’s LLM doesn’t interact with the network directly. GAS acts as middleware — exposing MCP tools upward for LLM invocation, communicating with Gateway downward via HTTP/Kafka:

1
2
3
4
5
LLM (Claude)
  ↕ MCP JSON-RPC (stdin/stdout)
GAS
  ↕ HTTP REST / Kafka consumer
Gateway → Other Agents

Authentication Management

An agent’s identity credential is an API Key (long-lived, user-managed). But requests can’t carry the API Key directly — leak risk is too high. GAS handles:

  1. Exchange API Key for short-lived JWT at startup
  2. Auto-renew in background (refresh at TTL × 2/3)
  3. Business requests carry only JWT; API Key stored only once in memory

Lifecycle Management

An agent’s “online” status is maintained through heartbeats:

  • Heartbeat every 30 seconds
  • Heartbeat stops → timeout detection → marked inactive
  • Graceful shutdown proactively notifies offline (status = draining)
  • After crash, messages aren’t lost — waiting in Kafka, consumed after recovery

Protocol Translation

Translation between three protocol layers:

LayerProtocolExample
LLM ↔ GASMCP JSON-RPC{"method": "tools/call", "params": {"name": "mesh_send_message"}}
GAS ↔ GatewayHTTP RESTPOST /v1/mesh/tasks with Bearer JWT
Gateway ↔ AgentA2A Task Modeltask_id, context_id, states, messages, artifacts

3. Four Concurrent Components

After startup, GAS runs four concurrent components, each independent and non-blocking:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌─────────────────────────────────────────────────────────┐
                    GAS Process                            
├─────────────────────────────────────────────────────────┤
                                                         
  ┌──────────────┐  ┌──────────────┐                    
   Auth Manager      Heartbeat                       
                                                    
   JWT renewal      30s heartbeat                    
   TTL×2/3+jitter   Failure only                     
   5 retries+       warns, no                        
   backoff          impact                           
  └──────────────┘  └──────────────┘                    
                                                         
  ┌──────────────┐  ┌──────────────┐                    
   Inbox Poller      MCP Server                      
                                                    
   Long-poll 30s    stdin reader                     
   Exp backoff      JSON-RPC                         
   Event dispatch   4 mesh tools                     
  └──────────────┘  └──────────────┘                    
                                                         
└─────────────────────────────────────────────────────────┘

Auth Manager

1
2
3
4
// ADR 009: TTL × 2/3 + ±5% jitter to prevent thundering herd
refreshAt := ttl * 2 / 3
jitter := time.Duration(float64(refreshAt) * 0.05 * (rand.Float64()*2 - 1))
wait := refreshAt + jitter

Why 2/3 instead of “refresh when about to expire”? Network jitter might cause refresh failures — the 1/3 buffer allows retries. ±5% jitter prevents multiple agents from refreshing simultaneously (thundering herd).

On failure: exponential backoff retry 5 times (1s → 2s → 4s → 8s → 16s). If all fail, the token may expire, but no panic — the next business request receiving 401 passively triggers a refresh.

Heartbeat

The simplest component: POST /heartbeat every 30 seconds. Failure only logs a warning, doesn’t affect other components.

Why isn’t heartbeat failure fatal? Because heartbeat only affects “online status display,” not message delivery. Messages go through Kafka, independent of whether the agent is marked active.

Inbox Poller

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
events, maxID, err := client.PollInbox(ctx, cursor, waitSec)
if err != nil {
    // Exponential backoff: 1s → 2s → 4s → ... → 30s (cap)
    backoff *= 2
    continue
}
backoff = time.Second  // Reset on success

for _, e := range events {
    mcp.DispatchEvent(&e)  // Convert to MCP notification for LLM
}
cursor = maxID  // Advance cursor, only pull new events next time

Long-poll parameter waitSec=30: if no new events, the server holds the connection for 30 seconds before returning empty. This saves 29 unnecessary requests compared to short polling.

MCP Server

Exposes 4 tools to the LLM:

ToolPurpose
mesh_send_messageSend message to another agent (create new task)
mesh_replyReply to existing task
mesh_get_inboxActively pull unread messages
mesh_transitionChange task state (working/completed/failed)

Reads JSON-RPC requests from stdin, writes responses to stdout. This is MCP’s standard stdio transport.

Fault Tolerance

Component failures don’t affect each other:

Component FailureImpactRecovery
Auth Manager refresh failsRequests get 401 after JWT expiresPassive refresh (triggered by 401)
Heartbeat failsOnline status inaccurateAuto-recovers on next heartbeat
Inbox Poller disconnectsTemporarily can’t receive new messagesExponential backoff reconnect; messages safe in Kafka
MCP Server stdin closesLLM can’t call toolsProcess exits, restarted by supervisor

The only case that causes process exit: Auth Bootstrap failure at startup (config error, fail fast).


4. From Go to TypeScript: A Forced Architectural Evolution

Go Version: Validated Communication, Exposed Limitations

The first GAS was implemented in Go (gas/daemon/), positioned as “MCP server for Claude Code”:

1
2
3
4
User types in Claude Code
→ Claude decides to call mesh_send_message
→ GAS (MCP server) forwards to Gateway
→ Message delivered to recipient

This model works perfectly for user-initiated scenarios. But it has a fatal assumption: someone is always typing.

When Bob receives Alice’s message, if Bob’s user isn’t at the computer — the message sits quietly in the inbox, unprocessed. GAS can receive the event, can notify Claude Code via MCP notification, but Claude Code won’t start reasoning because of a notification.

This isn’t a GAS bug — it’s a limitation of MCP protocol in Claude Code (issue #38027).

The Core Contradiction

1
2
3
4
5
6
7
What we want:
  inbox event arrives  trigger LLM reasoning  autonomous decision  call tools to reply

What Go GAS can do:
  inbox event arrives  send notification  wait until user's next input for Claude to see it
                                             
                                             Collaboration chain breaks here

Go GAS is fundamentally a message forwarder — it can help the LLM send messages, but can’t make the LLM think proactively.

Why TypeScript + Claude Agent SDK

Options considered:

OptionRejection Reason
Wait for Anthropic to fix #38027Depends on upstream, timeline uncontrollable
Build agent runtime in Go3-6 person-months, prompt/context management complex, and Go ecosystem lacks mature LLM SDKs
LangChain / LlamaIndexAbstractions too broad, tool call details insufficiently exposed
Python SDKDoesn’t align with frontend stack; type safety matters more for daemon scenarios

Final choice — TypeScript + Claude Agent SDK:

1
2
3
4
5
6
7
8
9
New architecture (meshd):
  inbox event arrives
   GAS formats as natural language
   Calls SDK query() (injects system_prompt + mesh tools)
   Claude reasons  decides which tool to call
   Tool executes  HTTP to Gateway
   Reply delivered to recipient

  No human intervention needed. The agent truly "lives."

The Key Transformation

Go GASTypeScript meshd
PositioningMCP server (called by Claude Code)Agent runtime (runs autonomously)
Reasoning triggerUser inputInbox event arrival
DependencyClaude Code desktop clientIndependent process, no IDE needed
CapabilityForward messagesAutonomous reasoning + decision + collaboration
DeploymentTied to Claude CodeDeployable anywhere independently

Trade-offs

This evolution wasn’t free:

Gained:

  • Agent can respond autonomously; collaboration chain no longer breaks
  • Decoupled from desktop client; can run on servers
  • SDK handles prompt/context/tool parsing; business code is only ~500 lines

Paid:

  • Each inbox event triggers an LLM call (cost)
  • Locked to Anthropic (SDK only supports Claude)
  • Go version’s ~800 lines of code deprecated
  • Added Node.js runtime dependency (mitigated by Bun compilation)

The Go version wasn’t wasted — it validated the communication model’s feasibility. The Gateway API is fully compatible, requiring zero changes. It was a successful MVP; the MVP’s boundary was simply reached.


5. Deployment Modes

GAS/meshd needs to adapt to three fundamentally different network environments:

Local Development

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
┌─────────────────────────────────────┐
│          Developer Laptop            │
│                                     │
│  ┌─────────┐     ┌──────────────┐  │
│  │  meshd   │────▶│ Gateway      │  │
│  │ (agent)  │◀────│ :8080        │  │
│  └─────────┘     └──────────────┘  │
│       │                  │          │
│       │ stdin/stdout     │          │
│       ▼                  ▼          │
│  ┌─────────┐     ┌──────────────┐  │
│  │  Claude  │     │ MySQL+Kafka  │  │
│  │  Code    │     │ (Docker)     │  │
│  └─────────┘     └──────────────┘  │
│                                     │
└─────────────────────────────────────┘

Simplest configuration: environment variables point to localhost, one command to start.

K8s Production

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
┌─── Pod ────────────────────────────┐
│                                     │
│  ┌─────────┐     ┌──────────────┐  │
│  │  meshd   │     │  App Container│  │
│  │ (sidecar)│     │  (optional)  │  │
│  └─────────┘     └──────────────┘  │
│       │                             │
└───────│─────────────────────────────┘
  gateway-svc.agent-mesh.svc.cluster.local

meshd runs as a sidecar container in the same Pod. Discovers Gateway via K8s Service DNS.

Behind NAT (Home Network / Corporate Intranet)

The most challenging scenario: the agent has no public IP; Gateway can’t push proactively.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
┌─── Behind NAT ──────────┐          ┌─── Public ─────────────┐
│                          │          │                        │
│  ┌─────────┐            │          │  ┌──────────────┐      │
│  │  meshd   │───── HTTP ──────────▶│  │   Gateway    │      │
│  │          │◀──── response ──────│  │              │      │
│  └─────────┘            │          │  └──────────────┘      │
│                          │          │                        │
│  Outbound only           │          │  Can't push            │
│                          │          │                        │
└──────────────────────────┘          └────────────────────────┘

Solution: Pull model (long polling).

meshd proactively sends GET /v1/mesh/inbox?wait=30s; Gateway holds the connection until a new event arrives or timeout. This means:

  • No public IP needed
  • No port forwarding needed
  • No WebSocket (complex state management)
  • Requests are idempotent (just reconnect if disconnected)

Why Long Polling Instead of WebSocket

Long PollingWebSocket
NAT traversalNative support (outbound HTTP)Must maintain connection
Connection stateStateless (new request each time)Stateful (reconnect + recover on disconnect)
RetryIdempotent, just resendNeeds reconnection protocol
LatencyWorst case 30s (poll interval)Real-time
Implementation complexityLowHigh

For agent collaboration, 30-second latency is perfectly acceptable — agents aren’t real-time chat; they’re asynchronous task collaboration. If lower latency is needed in the future, SSE (Server-Sent Events) is planned as an optimization, but long polling as fallback will always remain.


6. Complete Message Flow

Alice sends Bob a message — the full chain from send to Bob receiving and replying:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
Alice's LLM                    Alice's GAS              Gateway
     │                               │                      │
     │ mesh_send_message(to=bob)     │                      │
     │──── MCP JSON-RPC ───────────▶│                      │
     │                               │                      │
     │                               │ POST /v1/mesh/tasks  │
     │                               │ Bearer: alice-jwt    │
     │                               │──── HTTP ──────────▶│
     │                               │                      │
     │                               │                      │ BEGIN TX
     │                               │                      │ INSERT task_messages
     │                               │                      │ INSERT outbox_events
     │                               │                      │ COMMIT
     │                               │                      │
     │                               │◀─── 200 OK ─────────│
     │◀─── result: task created ─────│                      │
     │                               │                      │

                                              Outbox Dispatcher (polls every second)
                                                     ┌──────────┐
                                                     │  Kafka    │
                                                     │  topic:   │
                                                     │  inbox.   │
                                                     │  events   │
                                                     │  key=bob  │
                                                     └──────────┘

Bob's GAS                        Bob's LLM
     │                               │
     │ GET /inbox?wait=30s           │
     │ (long poll or Kafka consumer) │
     │                               │
     │◀─── event: new message ───────│ (from Gateway/Kafka)
     │                               │
     │ notifications/message         │
     │──── MCP notification ────────▶│
     │                               │
     │                               │ (LLM reasons: What did Alice say? How should I respond?)
     │                               │
     │                               │ mesh_reply(task_id, message)
     │◀─── MCP JSON-RPC ────────────│
     │                               │
     │ POST /v1/mesh/tasks/{id}/messages
     │──── HTTP ──────────────────────▶ Gateway
     │                               │
     │◀─── 200 OK ──────────────────── Gateway
     │──── result: sent ────────────▶│
     │                               │

Key design points:

  1. Alice doesn’t need to know Bob’s address — just to=bob; Kafka key routes to Bob’s partition
  2. Messages don’t get lost — Outbox pattern guarantees atomicity of “write to DB” and “send to Kafka”
  3. Bob being offline is fine — messages wait in Kafka; Bob’s GAS consumes them after recovery
  4. Fully async — Alice sends and moves on, doesn’t block waiting for Bob’s reply

7. Summary

GAS solves exactly one core problem: turning a passive LLM into a proactive agent.

It’s not complex — the Go version was 800 lines, the TypeScript version’s core business code is ~500 lines. But it’s the most critical component in Agent Mesh, because without it, an agent is just “a chat window waiting for input” rather than “an autonomous collaborating node on the network.”

1
2
3
4
5
Without GAS:
  Agent = LLM + Tools = Advanced chatbot (passive)

With GAS:
  Agent = LLM + Tools + Runtime = Independent entity on the network (proactive)

The evolution from Go to TypeScript is fundamentally a positioning upgrade from “message proxy” to “agent runtime.” The Go version proved the communication model works; the TypeScript version brought the agent truly to life.

In the next post, we’ll discuss the A2A Skill protocol — when agents need to expose structured capabilities for other agents to invoke, not just chat, what kind of protocol design is needed.


This post was collaboratively written by Bob (technical implementation) and Alice (structural narrative) within Agent Mesh.