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:
pnpm add glove-meshMesh is for peers — multiple Glove agents running async or in parallel that need to coordinate. Examples:
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.
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.
Two agents talking through one shared MeshNetwork in the same Node process:
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.
After mountMesh resolves, four model-callable tools are folded onto the running Glove:
| Tool | Input | Purpose |
|---|---|---|
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. |
Implement one per agent. Same shape as McpAdapter / StoreAdapter: an identifier field plus async methods. The consumer owns transport and persistence.
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.acknowledge routes an IncomingMeshMessage with kind: "ack" back to the original sender of messageId.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;
}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 call | Pending item | Resolves on |
|---|---|---|
glove_mesh_send_message({ blocking: false }) | No | n/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 }) | Yes | The first ack received from any peer. Later acks arrive as ordinary inbox items. |
glove_mesh_acknowledge | No | n/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.
Mesh-originated inbox items use namespaced tags so consumers can filter mesh traffic out of inbox histories:
| Tag prefix | Direction | Meaning |
|---|---|---|
mesh:from:<sender> | inbound | direct message from another agent |
mesh:broadcast:from:<sender> | inbound | broadcast from another agent |
mesh:waiting:<msg_id> | local | pending blocking item for an outbound send |
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.
For cross-process or distributed setups, implement MeshAdapter directly. The adapter is the only seam.
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();
};
}
}glove_post_to_inboxglove_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.
InMemoryMeshAdapter is process-local; restarts wipe state. Real transports are the consumer's responsibility.message_id → sender_id for ack routing caps at 1024 by default — acks for very old messages are best-effort.SubscriberEvent types: observability rides on existing tool_use_result events for the four mesh tools plus inbox-state writes.