Mesh Network

glove-mesh lets multiple Glove agents talk to each other — direct messages, broadcasts, acknowledgements — on top of the existing inbox primitive. The package is strictly additive (no glove-core changes) and ships no authentication; the consumer's MeshAdapter owns transport and any signing or verification.

Install it next to glove-core:

terminalbash
pnpm add glove-mesh

When to use mesh

Mesh is for peers — multiple Glove agents running async or in parallel that need to coordinate. Examples:

  • A planner agent assigning tasks to a worker agent on the same host.
  • A swarm of specialised agents (researcher, drafter, reviewer) collaborating on output.
  • Cross-process or cross-host agents passing messages over Redis pub/sub, NATS, or HTTP webhooks.

If you want a parent agent to delegate work to an isolated sub-task, use defineSubAgent instead — subagents run nested under the parent. Mesh is for peers, not children.

Mental model

Each agent owns its own StoreAdapter+inbox. The MeshAdapter is a per-agent view of the network (matches McpAdapter's per-conversation pattern). When agent A calls glove_mesh_send_message({ to: "b", ... }), the framework drops a status: "resolved" InboxItem with tag mesh:from:a into B's store. B's existing inbox-injection path surfaces it as a synthetic user message on B's next ask() — exactly like an externally-resolved inbox item.

The "shared inbox" is conceptual: the mesh is shared; the inbox stays per-agent.

Quick start (in-process)

Two agents talking through one shared MeshNetwork in the same Node process:

examples/mesh-demo/index.tstypescript
import { Glove, MemoryStore, Displaymanager, createAdapter } from "glove-core";
import { mountMesh, MeshNetwork, InMemoryMeshAdapter } from "glove-mesh";

const network = new MeshNetwork();

async function makeAgent(id: string, name: string, description: string) {
  const store = new MemoryStore(id);
  const glove = new Glove({
    store,
    model: createAdapter({ provider: "anthropic" }),
    displayManager: new Displaymanager(),
    systemPrompt: `You are ${name}. ${description}`,
    serverMode: true,
    compaction_config: { compaction_instructions: "Summarize the conversation." },
  }).build(store);

  await mountMesh(glove, {
    adapter: new InMemoryMeshAdapter(network, id),
    identity: { id, name, description, capabilities: ["chat"] },
  });

  return glove;
}

const planner = await makeAgent("planner", "Planner", "Plans tasks for the team.");
const worker  = await makeAgent("worker",  "Worker",  "Executes assigned tasks.");

await planner.processRequest(
  "Find an agent that can execute tasks and ask them to do something. Block until they respond.",
);
await worker.processRequest("Check your inbox and acknowledge anything you see.");
await planner.processRequest("Continue.");

For distributed setups — multiple processes, multiple hosts — implement MeshAdapter directly over your transport of choice. The four mesh tools and the inbox routing don't change.

What it gives the agent

After mountMesh resolves, four model-callable tools are folded onto the running Glove:

ToolInputPurpose
glove_mesh_send_message{ to, content, in_reply_to?, blocking? }Send a private message to a specific agent.
glove_mesh_broadcast{ content, blocking? }Send a message to every other registered agent.
glove_mesh_list_agents{ filter? }Discover who's on the network. Filter by capability or name substring.
glove_mesh_acknowledge{ message_id, note? }Lightweight delivery confirmation. Unblocks the original sender.

The MeshAdapter contract

Implement one per agent. Same shape as McpAdapter / StoreAdapter: an identifier field plus async methods. The consumer owns transport and persistence.

glove-mesh/coretypescript
interface MeshAdapter {
  identifier: string;

  // Identity / registration
  register(identity: AgentIdentity): Promise<void>;
  unregister(): Promise<void>;
  listAgents(): Promise<AgentIdentity[]>;
  getAgent(id: string): Promise<AgentIdentity | null>;

  // Outbound
  send(message: MeshMessage): Promise<void>;
  broadcast(message: Omit<MeshMessage, "to">): Promise<void>;
  acknowledge(messageId: string, note?: string): Promise<void>;

  // Inbound — framework registers ONE handler per agent
  subscribe(handler: (msg: IncomingMeshMessage) => Promise<void>): () => void;
}

Adapter guarantees the framework relies on:

  • send resolves when the transport has accepted the message, not when the recipient handles it.
  • broadcast excludes the sender from fan-out.
  • Handler errors must not bubble — log and continue so fan-out to other agents stays intact.
  • acknowledge routes an IncomingMeshMessage with kind: "ack" back to the original sender of messageId.

Message types

glove-mesh/coretypescript
interface AgentIdentity {
  id: string;
  name: string;
  description: string;
  capabilities?: string[];
  metadata?: Record<string, unknown>;
}

interface MeshMessage {
  id: string;                    // sender-generated
  from: string;                  // sender-claimed; unverified in v1
  to?: string;                   // omitted on broadcast
  in_reply_to?: string;
  content: string;
  created_at: string;            // ISO-8601
  blocking?: boolean;
  metadata?: Record<string, unknown>;
}

interface IncomingMeshMessage extends MeshMessage {
  kind: "direct" | "broadcast" | "ack";
  ack_of?: string;               // when kind === "ack"
  ack_note?: string;
}

Blocking sends

Set blocking: true on glove_mesh_send_message or glove_mesh_broadcast when the agent should not proceed until a response arrives. The framework inserts a pending blocking InboxItem tagged mesh:waiting:<msg_id>; when the ack/reply lands, that item flips to resolved and shows up via the standard [Inbox: N item(s) resolved] injection on the next turn.

Tool callPending itemResolves on
glove_mesh_send_message({ blocking: false })Non/a — returns immediately
glove_mesh_send_message({ blocking: true })Yes, tag mesh:waiting:<msg_id>Ack with ack_of === msg_id, or a reply (glove_mesh_send_message with in_reply_to === msg_id)
glove_mesh_broadcast({ blocking: true })YesThe first ack received from any peer. Later acks arrive as ordinary inbox items.
glove_mesh_acknowledgeNon/a — itself

Reply implies ack. A direct incoming message with in_reply_to: X does both: surfaces the reply body as a new resolved inbox item AND resolves the pending blocking item for X. The recipient doesn't need to call glove_mesh_acknowledge separately when replying.

Inbox tag convention

Mesh-originated inbox items use namespaced tags so consumers can filter mesh traffic out of inbox histories:

Tag prefixDirectionMeaning
mesh:from:<sender>inbounddirect message from another agent
mesh:broadcast:from:<sender>inboundbroadcast from another agent
mesh:waiting:<msg_id>localpending blocking item for an outbound send

No authentication

The from field on every MeshMessage is sender-claimed and not verified. If you need authenticated messaging, sign messages before calling adapter.send / adapter.broadcast and verify in your subscribe handler — glove-mesh itself stays out of the way. This mirrors how McpAdapter.getAccessToken keeps auth a consumer concern.

BYO transport (Redis pub/sub sketch)

For cross-process or distributed setups, implement MeshAdapter directly. The adapter is the only seam.

redis-mesh-adapter.tstypescript
import type { MeshAdapter, MeshMessage, IncomingMeshMessage, AgentIdentity } from "glove-mesh";
import type { Redis } from "ioredis";

export class RedisMeshAdapter implements MeshAdapter {
  identifier: string;

  constructor(private redis: Redis, private agentId: string) {
    this.identifier = `redis-mesh-${agentId}`;
  }

  async register(identity: AgentIdentity) {
    await this.redis.hset("mesh:agents", this.agentId, JSON.stringify(identity));
  }
  async unregister() {
    await this.redis.hdel("mesh:agents", this.agentId);
  }
  async listAgents(): Promise<AgentIdentity[]> {
    const raw = await this.redis.hgetall("mesh:agents");
    return Object.values(raw).map((s) => JSON.parse(s));
  }
  async getAgent(id: string) {
    const raw = await this.redis.hget("mesh:agents", id);
    return raw ? (JSON.parse(raw) as AgentIdentity) : null;
  }

  async send(msg: MeshMessage) {
    await this.redis.set(`mesh:msg:${msg.id}:sender`, msg.from, "EX", 3600);
    await this.redis.publish(`mesh:agent:${msg.to}`, JSON.stringify({ ...msg, kind: "direct" }));
  }
  async broadcast(msg: Omit<MeshMessage, "to">) {
    await this.redis.set(`mesh:msg:${msg.id}:sender`, this.agentId, "EX", 3600);
    await this.redis.publish("mesh:broadcast", JSON.stringify({ ...msg, kind: "broadcast", from: this.agentId }));
  }
  async acknowledge(messageId: string, note?: string) {
    const sender = await this.redis.get(`mesh:msg:${messageId}:sender`);
    if (!sender) throw new Error(`No record of message "${messageId}"`);
    const ack = {
      id: `ack_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
      from: this.agentId,
      to: sender,
      content: note ?? "",
      created_at: new Date().toISOString(),
      kind: "ack" as const,
      ack_of: messageId,
      ack_note: note,
    };
    await this.redis.publish(`mesh:agent:${sender}`, JSON.stringify(ack));
  }

  subscribe(handler: (msg: IncomingMeshMessage) => Promise<void>) {
    const sub = this.redis.duplicate();
    sub.subscribe(`mesh:agent:${this.agentId}`, "mesh:broadcast");
    sub.on("message", async (_chan, raw) => {
      try {
        await handler(JSON.parse(raw) as IncomingMeshMessage);
      } catch (err) {
        console.warn("[mesh-redis] handler:", err);
      }
    });
    return () => {
      sub.unsubscribe();
      sub.quit();
    };
  }
}

How this differs from glove_post_to_inbox

  • glove_post_to_inbox — "I will resolve this myself later from outside the conversation" (external service, webhook, cron).
  • glove_mesh_send_message — "I'm talking to another Glove agent on the mesh" (peer-to-peer).

Both write to the same StoreAdapter inbox surface; the tag prefix tells them apart.

Limitations (v1)

  • InMemoryMeshAdapter is process-local; restarts wipe state. Real transports are the consumer's responsibility.
  • The sender-table LRU that maps message_id → sender_id for ack routing caps at 1024 by default — acks for very old messages are best-effort.
  • Broadcast blocking resolves on the first ack, not all peers.
  • No new SubscriberEvent types: observability rides on existing tool_use_result events for the four mesh tools plus inbox-state writes.
  • No group/topic concept. Broadcast targets every registered agent.