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
| Mechanism | Runs where | Result comes back | Use when |
|---|---|---|---|
defineSubagent | Inside the parent's run and process, with its own Glove, prompt and store | As the parent's tool result | A bounded sub-task needs a different prompt or fewer tools, and the parent waits for the answer. |
defineCall | Inside the run; a plain function | Return value, validated by Zod | A deterministic step with a known output shape. No model involved. |
glove_foundry_spawn | A new queued run, new or existing instance and conversation | Not returned (command id only) | The model decides to start separate durable work now. |
glove_foundry_background | A new queued run on the same instance and conversation | Optional background.reconvene inbox item | Follow-up work on the same thread. |
| Typed client from code | Any instance and conversation | handle.wait() | Deterministic fan-out / fan-in that you, not the model, control. |
mesh | Between runs that are live at the same time, over your transport | Messages land in the receiver's inbox | Peers that must talk while both are working. |
| Workspace, inbox, tasks | Durable data | Whenever someone reads it | Handoffs that may outlive any single run. |
| Playbook subscriptions | The runtime, on inbound events | — | An inbound event should provision or wake a workforce. |
| Voice host | A realtime session that delegates to Foundry instances | The delegation tool's result | A 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:
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 option | Default / notes |
|---|---|
name, description, systemPrompt | Required. name matches /^[A-Za-z][\w-]*$/. |
tools | An array, or ({ name, prompt, parent }) => tools (may return a Promise or Effect). Default: no tools. |
model | The parent's model. |
durable | false: 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. |
serverMode | The parent's. |
compactionInstructions | “Preserve the <name> subagent's findings and unresolved work.” |
maxRetries, maxConsecutiveErrors, compactionLimit, maxTurns, enableToolResultSummary | Glove defaults. |
hooks, skills, subagents | Mounted on the child. |
layers, subscribers | Mounted 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.
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: falsefor calls only your code should use. callscan be a resolver, so a call can close over the instance or message.ctx.spawn(message)insiderunis unrelated to agent spawning: it runs the definition's layeredspawnhook (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.
| Input | Default when omitted |
|---|---|
message (string, required) | — |
definitionId | The caller's definition (a route such as "billing"). |
agentId | A new instance of definitionId in the caller's workspace, with context.spawnedByRunId. An unknown id creates an instance with that id. |
conversationId | A new conversation (unknown ids are created with that id), with context.spawnedByRunId. |
payload | None. 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.
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_URLfor the agent process, and pass anauthorizationadapter tocreateFoundryClientif the server usesrequestAuthorization. - At most
execution.maxConcurrentruns 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; passtimeoutMsfor 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:
interface FoundryMeshConfig {
readonly adapter: MeshAdapter; // from glove-mesh: your transport, one adapter per agent
readonly identity?: Omit<AgentIdentity, "id"> & { readonly id?: string };
}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:
idandname= the instance id,description= the definition's;metadataalways includesdefinitionId,conversationIdandworkspaceId. - Mounts
glove_mesh_send_message,glove_mesh_broadcast,glove_mesh_list_agentsandglove_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
InMemoryMeshAdaptercannot 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:
| Need | Primitive |
|---|---|
| Hand a result to a specific instance or conversation | Shared inbox item with agentId/conversationId, loaded on its next turn via inboxes |
| Make sure only one agent takes a job | compareAndSetWorkspaceEntry with null (a claim) |
| Show who owns what and how far it got | Tasks: open → in-progress → completed/cancelled |
| Share a document | A 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.
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 iss2sDrivenModel({ provider: "gemini" })and whoserunwraps the assembled Glove inRealtimeAgentand 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_workforcecallsclient.sendon thecampaign-orchestratorinstance and returns the batch run id at once, so the call never blocks on the work.agents/campaign-orchestrator— a headlessrunthat plans waves and gives each campaign its own conversation on the lead instance (createConversation→send→wait), emittingbraind.campaign.*events.agents/lead— Mara'srunconvenes 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.tsin one workspace. Run it withGEMINI_API_KEYset andpnpm --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.
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.