Give agents a world / Multi-agent systems

Multi-agent systems

Foundry does not impose one orchestration pattern. A support agent can ask a read-only researcher for facts, hand a refund to a billing agent, fan a batch of tickets out to parallel conversations, or sit behind a live voice line — and every hop shows up in the same event trace.

Choose the smallest boundary

MechanismRuns whereResult comes backUse when
defineSubagentInside the parent's run and process, with its own Glove, prompt and storeAs the parent's tool resultA bounded sub-task needs a different prompt or fewer tools, and the parent waits for the answer.
defineCallInside the run; a plain functionReturn value, validated by ZodA deterministic step with a known output shape. No model involved.
glove_foundry_spawnA new queued run, new or existing instance and conversationNot returned (command id only)The model decides to start separate durable work now.
glove_foundry_backgroundA new queued run on the same instance and conversationOptional background.reconvene inbox itemFollow-up work on the same thread.
Typed client from codeAny instance and conversationhandle.wait()Deterministic fan-out / fan-in that you, not the model, control.
meshBetween runs that are live at the same time, over your transportMessages land in the receiver's inboxPeers that must talk while both are working.
Workspace, inbox, tasksDurable dataWhenever someone reads itHandoffs that may outlive any single run.
Playbook subscriptionsThe runtime, on inbound events—An inbound event should provision or wake a workforce.
Voice hostA realtime session that delegates to Foundry instancesThe delegation tool's resultA speaking agent must stay responsive while durable work runs elsewhere.

Subagents with least-privilege tools

A subagent is a separate Glove the parent model can call. When a definition has subagents, Glove registers the glove_invoke_subagent tool; an @name mention in the user's text is a routing hint. The child runs to completion inside the parent's tool call and its final answer becomes the tool result.

The tools option can be a function of the fully assembled parent, resolved only when the subagent is invoked — after installations, MCP servers and message-dependent tools are mounted. That lets a delegate inherit a read operation without inheriting the parent's write surface. Foundry has no built-in projection helper; parent.tools exposes the registered tools, so a five-line function does it:

agents/billing/agent.tstypescript
import { getToolJsonSchema, type GloveFoldArgs, type IGloveRunnable } from "glove-core";
import { createAdapter } from "glove-core/models/providers";
import { defineAgent, defineSubagent } from "glove-foundry";
import { FileStore } from "../../lib/file-store.js"; // durable transcript — see Persistence

// Re-expose selected tools of the assembled parent, by name.
function projectTools(parent: IGloveRunnable, names: ReadonlyArray<string>): ReadonlyArray<GloveFoldArgs<unknown>> {
  return parent.tools
    .filter((tool) => names.includes(tool.name))
    .map((tool) => ({
      name: tool.name,
      description: tool.description,
      jsonSchema: getToolJsonSchema(tool),
      do: (input, _display, _glove, signal) => tool.run(input, undefined, signal),
    }));
}

export default defineAgent({
  description: "Handles refunds and billing questions",
  model: () => createAdapter({ provider: "openrouter", model: "openai/gpt-4.1-mini", stream: true }),
  systemPrompt: "You handle billing. Ask @order-researcher to gather facts before deciding.",
  store: ({ conversationId }) => FileStore.open(conversationId),
  subagents: [
    defineSubagent({
      name: "order-researcher",
      description: "Look up orders and summarize what happened. Read-only.",
      systemPrompt: "Gather facts about one order. Never change anything. Return a short summary.",
      tools: ({ parent }) => projectTools(parent, ["lookup_order", "glove_foundry_workspace"]),
    }),
  ],
});
defineSubagent optionDefault / notes
name, description, systemPromptRequired. name matches /^[A-Za-z][\w-]*$/.
toolsAn array, or ({ name, prompt, parent }) => tools (may return a Promise or Effect). Default: no tools.
modelThe parent's model.
durablefalse: a fresh child store per invocation via the parent store's createSubAgentStore. true: the same child store for that name, so history carries over between invocations.
serverModeThe parent's.
compactionInstructions“Preserve the <name> subagent's findings and unresolved work.”
maxRetries, maxConsecutiveErrors, compactionLimit, maxTurns, enableToolResultSummaryGlove defaults.
hooks, skills, subagentsMounted on the child.
layers, subscribersMounted on the child when it is invoked (layers accept configureLayer(layer, config)) and disposed when its request ends. They get the same synthetic context as configure.
configure(context)Runs after the child is built and its layers and subscribers are mounted. Its context is synthetic: conversationId and runId are subagent:<name>, workspaceId is "subagent", emit is a no-op.

The child uses the parent's display manager and, unless overridden, its model and serverMode; prompt, store and compaction are its own. Parent subscribers still record subagent_invoked and subagent_completed, so delegation appears in the parent run's trace (category extension). The subagents field itself can be a resolver, so the set of specialists can depend on the instance or message.

Typed calls

defineCall is a schema-checked function that lives in the run. By default (exposeToAgent: true) it is also a tool the model can call; your own code calls it with ctx.invoke(name, input) from run or configure. Input is parsed with input, the handler's return value with output; a thrown error or failed parse becomes an error tool result for the model.

agents/billing/agent.tstypescript
import { defineCall } from "glove-foundry";
import { z } from "zod";

const RefundQuote = z.object({ orderId: z.string(), refundable: z.boolean(), amount: z.number() });

// inside defineAgent({ ... })
calls: [
  defineCall({
    name: "refund_quote",
    description: "Compute how much of an order can be refunded.",
    input: z.object({ orderId: z.string() }),
    output: RefundQuote,
    handler: async ({ orderId }) => {
      // your code: const order = await billingApi.getOrder(orderId)
      const order = { id: orderId, paid: 42, refunded: 0 };
      return { orderId: order.id, refundable: order.refunded < order.paid, amount: order.paid - order.refunded };
    },
  }),
],
run: async (_glove, ctx) => {
  const orderId = /\b\d{4,}\b/.exec(ctx.messageText)?.[0];
  if (!orderId) return ctx.defaultRun();
  const quote = (await ctx.invoke("refund_quote", { orderId })) as z.output<typeof RefundQuote>;
  ctx.emit({ type: "billing.quote.ready", data: { orderId, refundable: quote.refundable } });
  return ctx.defaultRun(`${ctx.messageText}\n\n[Refund quote: ${JSON.stringify(quote)}]`);
},
  • Names match /^[A-Za-z][A-Za-z0-9_-]*$/; two calls with one name fail assembly.
  • Set exposeToAgent: false for calls only your code should use.
  • calls can be a resolver, so a call can close over the instance or message.
  • ctx.spawn(message) inside run is unrelated to agent spawning: it runs the definition's layered spawn hook (S2S/S2V) or the normal loop. See defineAgent & assembly.

Spawn a separate run

Every run has the built-in glove_foundry_spawn tool. The model uses it to start durable work elsewhere — for example the support agent handing a refund dispute to the billing definition — without waiting for it.

InputDefault when omitted
message (string, required)—
definitionIdThe caller's definition (a route such as "billing").
agentIdA new instance of definitionId in the caller's workspace, with context.spawnedByRunId. An unknown id creates an instance with that id.
conversationIdA new conversation (unknown ids are created with that id), with context.spawnedByRunId.
payloadNone. Passed as the child request's payload.

The tool returns { commandId, accepted: true, type: "spawn" } immediately. The runtime queues the child run (source.kind: "spawn") and records core.command.accepted with the childRunId on the parent run, or core.command.failed with the error. The result is not delivered back; have the child report through the shared inbox, a task or a workspace entry. Tell the model the real ids it may use in its system prompt. For later or recurring work use glove_foundry_schedule (see Automation).

Fan out from code with the typed client

When the orchestration itself should be deterministic, write it in a run function and drive other instances through glove-foundry/client. This is how examples/foundry-braind-storm runs campaign batches. Each ticket gets its own conversation on the support instance, so histories stay separate and the runs execute in parallel.

agents/triage/agent.tstypescript
import { createAdapter } from "glove-core/models/providers";
import { defineAgent } from "glove-foundry";
import { createFoundryClient } from "glove-foundry/client";
import { FileStore } from "../../lib/file-store.js"; // durable transcript — see Persistence

const foundry = createFoundryClient({ baseUrl: process.env.FOUNDRY_URL ?? "http://127.0.0.1:4141" });

interface Ticket { readonly id: string; readonly summary: string }

export default defineAgent({
  description: "Fans a batch of tickets out to the support agent, one conversation each",
  model: () => createAdapter({ provider: "openrouter", model: "openai/gpt-4.1-mini", stream: true }),
  systemPrompt: "Coordinate ticket batches.",
  store: ({ conversationId }) => FileStore.open(conversationId),
  run: async (_glove, ctx) => {
    const tickets = (ctx.request.payload as { tickets?: Ticket[] } | undefined)?.tickets ?? [];
    const results = await Promise.all(tickets.map(async (ticket) => {
      const conversation = await foundry.createConversation("support-desk", {
        id: `ticket-${ticket.id}`,
        title: ticket.summary,
        context: { parentRunId: ctx.runId },
      });
      const handle = await foundry.send("support-desk", conversation.id, ticket.summary, {
        context: { parentRunId: ctx.runId },
      });
      ctx.emit({ type: "triage.ticket.started", data: { ticketId: ticket.id, runId: handle.id } });
      const run = await handle.wait({ pollMs: 500, timeoutMs: 10 * 60_000 });
      ctx.emit({ type: "triage.ticket.finished", data: { ticketId: ticket.id, status: run.status } });
      return { ticketId: ticket.id, status: run.status, runId: handle.id };
    }));
    return { tickets: results };
  },
});
  • The run reaches the server over HTTP: set FOUNDRY_URL for the agent process, and pass an authorization adapter to createFoundryClient if the server uses requestAuthorization.
  • At most execution.maxConcurrent runs execute at once (default 5); the rest queue. An orchestrator that waits holds one of those slots, so size the limit for parent plus children.
  • handle.wait() polls every 150 ms and times out after 60 s by default; pass timeoutMs for long work.

Parallel campaigns are conversations

Model concurrent work as separate conversations (or instances) and runs, never as branches inside one prompt. Each gets its own history, run id, trace and cancel button; sequential dependencies become explicit waits in your orchestrator. The braind-storm campaign orchestrator accepts up to eight campaigns and runs them parallel, sequential, or auto (waves computed from dependsOn), one lead conversation per campaign.

Mesh: peers that talk while running

The mesh field mounts Glove Mesh on the run. Its value (or resolver result) is a FoundryMeshConfig:

glove-foundry (definition.ts)typescript
interface FoundryMeshConfig {
  readonly adapter: MeshAdapter; // from glove-mesh: your transport, one adapter per agent
  readonly identity?: Omit<AgentIdentity, "id"> & { readonly id?: string };
}
agents/billing/agent.tstypescript
import { createMeshAdapter } from "../../lib/mesh.js"; // your code: a MeshAdapter over Redis, NATS, …

// inside defineAgent({ ... })
mesh: (_agent, ctx) => ({
  adapter: createMeshAdapter(ctx.agentId),
  identity: { name: "Billing", description: "Refunds and invoices", capabilities: ["refunds"] },
}),
  • Identity defaults: id and name = the instance id, description = the definition's; metadata always includes definitionId, conversationId and workspaceId.
  • Mounts glove_mesh_send_message, glove_mesh_broadcast, glove_mesh_list_agents and glove_mesh_acknowledge. Incoming messages become resolved inbox items, so the store must be inbox-capable.
  • The agent registers when the run assembles and adapter.unregister() runs at cleanup: a peer is reachable only while one of its runs is executing.
  • Each run is its own process, so InMemoryMeshAdapter cannot connect two Foundry runs. Use a transport-backed adapter, or build the whole mesh inside one run as braind-storm does.

Coordinate through shared work

For handoffs that must survive restarts, coordinate through workspace data rather than live messages:

NeedPrimitive
Hand a result to a specific instance or conversationShared inbox item with agentId/conversationId, loaded on its next turn via inboxes
Make sure only one agent takes a jobcompareAndSetWorkspaceEntry with null (a claim)
Show who owns what and how far it gotTasks: open → in-progress → completed/cancelled
Share a documentA workspace entry holding a path or object key, not the body

Voice hosts delegate durable work

A realtime voice session owns turn-taking, interruption and audio; it is not a durable Foundry run. Keep it thin and give it a delegation tool that sends work to a persisted instance and conversation through the typed client. The work then has a run id, a trace, approvals and a transcript. Use glove-voice-s2s (RealtimeAgent, Gemini Live, OpenAI Realtime, GPT-Live) for speech-to-speech or glove-voice for an STT → Glove → TTS pipeline; see Realtime voice.

agents/phone-line/delegate.tstypescript
import type { GloveFoldArgs } from "glove-core";
import { createFoundryClient } from "glove-foundry/client";
import { z } from "zod";

const foundry = createFoundryClient({ baseUrl: process.env.FOUNDRY_URL ?? "http://127.0.0.1:4141" });

export const delegateToSupport: GloveFoldArgs<{ request: string }> = {
  name: "delegate_to_support",
  description: "Hand a customer request to the support team. Returns a run id to read back.",
  inputSchema: z.object({ request: z.string().min(3) }),
  async do({ request }) {
    const handle = await foundry.send("support-desk", "support-voice", request, {
      context: { channel: "voice" },
    });
    return { status: "success", data: { accepted: true, runId: handle.id } };
  },
};

Reference implementation: examples/foundry-braind-storm. A brand workforce where you talk to Mara, the lead:

  • agents/briefing-line — a Foundry agent whose model is s2sDrivenModel({ provider: "gemini" }) and whose run wraps the assembled Glove in RealtimeAgent and serves a local WebSocket call room. Its tools: get_storm_briefing (read the latest work), record_direction (persist spoken direction), launch_campaign_workforce (start real work).
  • launch_campaign_workforce calls client.send on the campaign-orchestrator instance and returns the batch run id at once, so the call never blocks on the work.
  • agents/campaign-orchestrator — a headless run that plans waves and gives each campaign its own conversation on the lead instance (createConversation → send → wait), emitting braind.campaign.* events.
  • agents/lead — Mara's run convenes four specialists (scout, strategist, maker, critic) over an in-process Glove Mesh with one shared working environment, passing /out/... paths instead of document bodies.
  • All instances and conversations are seeded in foundry.application.ts in one workspace. Run it with GEMINI_API_KEY set and pnpm --filter glove-foundry-braind-storm dev.

Make orchestration visible

Emit a short, safe event at every milestone: who handed what to whom, for which conversation, and where the artifact is. Use ctx.emit in run, calls and layers, or ctx.controls.emit in resolvers. Events are stored with the run id and definition, appear in the inspector's run trace (custom types land in category system), and can be read with client.getEvents({ runId }), handle.events() or the live stream at GET /api/events with Accept: text/event-stream.

agents/support/agent.tstypescript
ctx.emit({
  type: "support.handoff",
  data: {
    to: "billing",
    purpose: "refund above auto-approval limit",
    conversationId: ctx.conversationId,
    artifact: "/out/refund-4411.md",
  },
});

Foundry already records the structural events: foundry.core.command for every spawn, background, schedule and transmit command (transmit payloads are withheld), core.command.accepted with the child run id, core.command.failed, and the subagent bracket. Emit intent and outcomes, never secrets or private model reasoning. See Inspector & WebChat.