Durable subagents
A durable subagent is an agent instance owned by another agent instance. You declare a named worker in defineAgent; Foundry creates that worker on first use and keeps its identity, conversations and owned data available for future work.
Use durable subagents when one agent needs several intelligent workers with separate responsibilities. Each worker can have its own model, instructions, tools, memory access and run limits. A worker can handle many tasks over time, receive events independently, and own further workers. The parent can keep serving requests while that work proceeds.
This composition works for chat agents, background services, scheduled work and applications with no conversational front at all. What connects the agents is an ownership relationship and durable, addressed event delivery. A live conversation is one possible place to receive the results.
What exists in code, and what exists in storage
The declaration describes a relationship between agent definitions. The running application creates a separate instance of that relationship for each parent. These identities matter when deciding which state is shared and where a message will arrive:
durableSubagents: { analysis: { agent: worker } }One relationship declared in the coordinator definitionanalysiscreated on first useanalysiscreated on first use| Concept | Meaning |
|---|---|
| Worker definition | A normal defineAgent value: the model, instructions and capabilities from which workers are assembled. Several parents or slots can reuse it. |
| Named slot | A key in durableSubagents, such as analysis. It selects the definition, inheritance and delivery defaults for that relationship. |
| Owned worker instance | One persisted agent for a particular parent instance and slot name. It has its own instance ID in the parent's workspace. |
| Conversation | A thread owned by the worker. A worker can have a default event conversation and separate conversations for individual tasks. |
| Run | An execution that assembles the worker and processes work in one conversation. The same worker instance can be used by many runs. |
If two customer agents use the same parent definition, each gets its own analysis worker. If one parent declares two slots using the same worker definition, it gets two distinct workers. Reusing a definition does not attach an unrelated existing agent instance to the parent.
The durable part is the identity and persisted state. Foundry assembles an agent for each run; it does not keep a JavaScript process alive simply because the worker exists. An idle worker can remain available for months without a continuous model session.
Declare the workers an agent owns
Keep the worker as an ordinary agent definition, then import it into the parent. Model and storage adapters remain normal Foundry configuration; there is no special worker harness to construct.
import { defineAgent } from "glove-foundry";
import { workerModel } from "../../lib/models.js";
export default defineAgent({
description: "Analyze assigned work and report useful findings",
model: workerModel,
systemPrompt: "Complete assigned work. Ask for missing information when needed.",
});import { defineAgent } from "glove-foundry";
import worker from "../worker/agent.js";
import { primaryModel } from "../../lib/models.js";
export default defineAgent({
description: "Coordinate requests and ongoing work",
model: primaryModel,
systemPrompt: "Help with incoming requests and coordinate your workers.",
durableSubagents: {
analysis: {
agent: worker,
description: "Detailed analysis that can continue independently",
eventDelivery: { whenIdle: "wake" },
},
},
});An inline defineAgent also works as the slot's agent. The reused definition keeps its own explicit capabilities; the slot can add selected inheritance and replace its event-delivery defaults. The source definition is not mutated, so using it elsewhere does not grant the parent's capabilities there.
Slots are declared statically. Names are lowercase slugs; parent is reserved. Foundry derives a definition route such as coordinator/workers/analysis. Workers can declare their own durableSubagents, but cyclic definition graphs and route collisions fail discovery. See agent assembly for the ordinary definition fields.
When a worker is created and reused
Declaring a slot makes a worker available. It does not create every possible instance at startup. Foundry provisions the owned instance atomically when a message or delegated task first needs it. Your application can also ensure the instance explicitly:
import { createFoundryClient } from "glove-foundry/client";
const client = createFoundryClient({ baseUrl: process.env.FOUNDRY_URL! });
const parent = await client.agent("coordinator").create({
workspaceId: "workspace-a",
});
const worker = client.subagent(parent.id, "analysis"); // a client handle; no request yet
const instance = await worker.ensure(); // create once, or return the existing instance
await worker.send("Review the current plan."); // also ensures the worker if neededThe ownership key is the parent instance ID plus the slot name. Repeated sends, new tasks and controller restarts reuse that worker when the application data adapter is persistent. Ensuring it does not start a model run. Listing workers with client.subagents(parent.id) and inspecting worker.data() do not provision or reactivate workers.
A new parent instance gets new workers even if it uses the same definition. Changing a slot name identifies a different worker; it is not a rename of the stored instance. Keep names stable when you intend to retain the same state.
Choose what the worker inherits
Parenthood does not automatically grant access to the parent's context, tools, memory or installed apps. The worker starts with the capabilities in its own definition. The slot's inherit field adds the specific parent surfaces it needs:
// Within durableSubagents. documents is a parent app definition.
analysis: {
agent: worker,
inherit: {
context: true,
tools: ["lookup_status"],
memory: { scope: "shared", access: "reader", profiles: ["case-notes"] },
apps: [restrictApp(documents, {
tools: ["search", "read_document"],
operations: ["documents.read"],
})],
},
}Import restrictApp from glove-foundry. The selected app and memory profiles must be in the parent's capability catalogue, and the parent must have selected or installed them as appropriate. Applications explains availability versus installation.
| Selection | What the worker receives |
|---|---|
context: true | A copy of parent instance context when the worker is first provisioned. Later parent edits do not overwrite worker context. |
tools | Always-on parent tools selected by exact name. The parent tool resolver runs in the child's context, and approval requirements remain in force. |
memory | Selected parent memory profile definitions, with explicit storage scope and reader or curator access. |
apps | Restricted views of apps currently installed on the parent, using the parent's selected account and installation configuration. |
Transcripts are not inherited. Neither are the parent's schedules, playbooks, outbound route grants or MCP installations. Installed shared tools are not selected by inherit.tools; declare them on the worker or expose the needed operations through an app.
Shared and isolated memory
scope: "shared" gives the worker access to the selected parent-scoped memory. It is the same underlying memory, so changes by an authorized curator can become visible to both agents. scope: "isolated" uses the same profile definitions under the child's storage identity. Omit memory inheritance when only the worker's own profiles should be available.
scope: "shared"scope: "isolated"Inherited memory is read-only by default. Explicit access: "curator" can preserve curator abilities the parent already has; it cannot upgrade a parent reader. Reader inheritance also removes pinned-context mutation tools. Shared memory is scoped to the parent agent, not to whichever parent chat happens to be active.
Memory adapters must namespace storage by the identity supplied to their factories. Foundry cannot isolate an adapter that always opens the same global namespace. Profiles with custom mount functions are rejected for inheritance because Foundry cannot inspect their permissions. See memory profiles for configuration.
App permissions and revocation
restrictApp(app, { tools, operations, transmissions }) narrows an existing app. Each list is an exact allowlist; omitted lists grant nothing. tools selects contributed tools, operations gates withAccountSession, and transmissions selects the app's transmission surfaces. Chaining restrictions intersects access.
App inheritance borrows a current installation rather than copying it permanently into the worker's desired state. Foundry resolves it on each run and rechecks borrowed app authority before tool and account-session operations. Removing the parent installation revokes future use, including in a long-running worker; revocation cannot undo an external effect already in progress.
A modifier cannot acquire credentials, grant an outbound route or bypass tool approval. Route grants and bindings remain separate, and API calls still use the application's request-authorization adapter. These controls constrain model-visible capabilities; tool implementations, app installers and memory factories remain trusted application code.
Direct messages and delegated tasks
A durable worker is a place to send work over time. Task delegation is one way to organize that work. It adds task status, questions and a correlated result to the worker's independent lifetime.
| Operation | Use it for | What comes back |
|---|---|---|
| Direct message | A new fact, a changed priority, a spontaneous finding, or coordination without opening a tracked task. | A durable acceptance receipt. A response is separate work; there is no automatic reply loop. |
| Task delegation | An assignment whose progress, clarification questions and final outcome should be associated with the caller. | A task ID immediately; questions and the final result return to the originating conversation. |
Both operations use the same durable event-delivery layer. Neither requires a mesh inbox. Existing sibling delegates still work; a declared durable slot is automatically a delegation target, so you do not repeat its name in delegates.
Send a message to a worker or parent
Foundry exposes the following model tools. These calls describe what the agent can do; they are not functions to import into application code:
// Parent → its named worker.
glove_foundry_message({
to: "analysis",
message: "The source data has changed. Use revision 12 for future work.",
delivery: { id: "source-revision-12", whenIdle: "wake" },
});
// Worker → its actual parent, in a specific parent conversation.
glove_foundry_message({
to: "parent",
message: "A finding needs attention.",
delivery: { conversationId: "<parent conversation ID>", whenIdle: "wake" },
});The model tool can address declared children and the actual parent, rather than arbitrary instance IDs. Messages include a host-readable metadata.replyTo address in ctx.events. Application code can use that origin when choosing where a reply belongs.
Your application can contact the worker directly through the client, even when its parent has no active run. Use a stable ID when retrying the same message:
const worker = client.subagent(parentId, "analysis");
const receipt = await worker.send("Use revision 12 for future work.", {
id: "source-revision-12",
whenIdle: "wake",
});
// An app can also publish an event to an exact existing conversation.
await client.emitEvent(
{ agentId: receipt.agentId, conversationId: receipt.conversationId },
{ id: "revision-13", source: "app:documents", kind: "source.changed", content: "Revision 13 is available." },
{ whenIdle: "defer", deferTimeoutMs: 30_000 },
);Choose the conversation that should receive it
An instance ID identifies the worker. A conversation ID identifies the particular context in which it will process the event. A direct message without delivery.conversationId uses the recipient's stable default event conversation, created when needed. It does not select the latest chat or the currently running task.
Set delivery.conversationId to reach an existing conversation belonging to that recipient. Foundry validates instance ownership and workspace. This applies in both directions: a worker's message to parent needs an explicit conversation ID to reach a particular user chat. To update a delegated task, address its conversation or use the task reply tool.
A message receipt contains messageId, agentId, conversationId and a runId when one is handling the event. Acceptance means the journal owns the event, not that a model has answered it. Reusing an ID with different content or policy fails; new information should get a new ID.
Delegate a tracked task
// Parent: start a task on the existing owned worker.
glove_foundry_delegate({ to: "analysis", task: "Assess the proposed change and report risks." });
// Worker, while handling that task: request clarification.
glove_foundry_ask({ question: "Which constraints are mandatory?" });
// Parent: answer or add instructions to the task.
glove_foundry_reply({ delegationId: "<task ID>", message: "Preserve the existing API." });
// The worker's final answer returns to the originating conversation automatically.Each task creates a separate conversation on the same owned worker instance. A new task does not create a new worker. Questions and results retain their task correlation and return to the conversation that delegated the work. Direct messages to the worker's default conversation remain separate from those tasks.
For delegation between separately managed sibling agents, see layered agents. For a transient nested tool invocation, see the multi-agent composition guide.
Configure how events reach a runtime
Delivery is addressed to an agent and conversation before any model is invoked. Foundry first persists the event, target and policy. If that conversation has a live run, the event joins its native event stream at the next available boundary. Otherwise, the idle policy determines when a new event turn starts.
- Messages, delegated work and application events resolve to an agent instance and conversation. An omitted message conversation selects the recipient's stable event conversation; it does not guess the latest chat.
- The journal stores the event ID, destination and delivery policy before dispatch. The sender receives durable acceptance; a response may come later.
- If the conversation has a live run, the event joins its native runtime context at the next boundary. An executing tool is allowed to finish; realtime sessions control when they respond.
wakestarts an event turn.deferwaits for another turn, or fordeferTimeoutMswhen configured. The controller owns that wait; no worker process must remain alive.- Recovery reconciles completed receipts and retries unfinished delivery to the stored target. A deferred deadline is measured from original acceptance. Delivery is at least once, so repeated external effects need idempotent handling.
- A successful receiving run acknowledges consumed events. An event that expires before consumption is skipped. Acknowledged and expired IDs remain as deduplication receipts until owned-data purge.
There are three configuration levels: the worker definition's eventDelivery supplies defaults; a slot's eventDelivery replaces that default object for the owned worker; an individual send overrides fields of the effective recipient policy.
// On an ordinary agent definition:
eventDelivery: { whenIdle: "wake" }
// In one parent's durableSubagents slot:
analysis: {
agent: worker,
eventDelivery: { whenIdle: "defer", deferTimeoutMs: 60_000 },
}
// For a particular message:
await workerHandle.send("Handle this update promptly.", {
id: "update-42",
whenIdle: "wake",
expiresAt: new Date(Date.now() + 120_000).toISOString(),
});| Policy | Idle conversation |
|---|---|
whenIdle: "wake" | Starts an event turn. This is the default when no policy is supplied. |
whenIdle: "defer", no effective timeout | Waits for the next turn in that conversation. |
defer with deferTimeoutMs | Joins the next turn, or wakes at the deadline measured from durable acceptance. Restarting the controller does not reset that deadline. |
expiresAt on the message or event | Discards stale input before consumption, including during recovery. |
These policies do not interrupt an executing tool or infer whether a user is present. A waking event can bring deferred events into the same turn. Different conversations can run independently within the configured execution limits. Task starts, questions, replies and results use the same delivery layer and recipient defaults.
Spontaneous delivery into runtime context
A delivered message becomes a native AgentEvent. During a normal model/tool loop it joins the context at an event boundary and is saved to history. An idle event turn uses processEvents(). Custom Foundry handlers can read ctx.events or call ctx.defaultRun() to use the normal loop.
Standing state, such as the current list of open tasks, is supplied as live runtime-context snapshots. Arriving messages are events. This lets a worker report new information while the parent is doing other work, without turning every arrival into a synthetic user request.
A realtime runtime can hold the turn queue and consume native events as part of its session. It decides when a response can be spoken or displayed, for example at a quiet pause. The controller still owns durable targeting and delivery; a provider socket or live room is not what makes the worker durable.
Use client.emitEvent, runtime.emitEvent or the Foundry messaging tool for durable acceptance. A direct glove.emitEvent call inside a process does not create a Foundry journal record. Optional live mesh transport has its own configuration and is not required here. See runtime context and spontaneous events for the native primitives.
Take events in a handler
A custom handler or run that never runs the default turn has every event that started the run acknowledged when it returns. To handle addressed events yourself (agent messages and transmissions, not delegation, mesh or approval deliveries), register context.onEvent: then nothing but your ack() consumes them.
async handler(context) {
const stop = context.onEvent(async (handled) => {
// handled.id is the journal id: the same on every redelivery.
if (await notices.alreadySent(handled.id)) return handled.ack();
if (await notices.send(handled.id, handled.event)) handled.ack();
// Not acknowledged: it stays pending and comes back.
}, (event) => event.source === "acme:notices");
await work();
await stop(); // waits for handler calls still running
}- The handler gets the events that started the run (
initial: true) and those that arrive while it is registered, one call per event. - Registering, returning and the run ending acknowledge nothing; in a mixed batch only the events you acknowledged are consumed.
- An event left unacknowledged that arrived mid-run goes back to the runtime when the run ends and follows its
whenIdlepolicy. One that started the run is in the next run's events. - The run waits for handler calls still running before it reports what was consumed. The returned function stops taking events and waits for them too.
- Delegation updates, mesh messages and every run without
onEventbehave as before.
Read current facts before a turn sees an event
An event can sit for a while before a turn reads it, so text copied into it can be stale. prepareEvent on the definition reads current facts just before a text turn sees an addressed event, whether the event starts the run or joins a turn already going:
prepareEvent: async (event, context) => {
const meeting = await meetings.get(event.metadata?.meetingId as string);
if (!meeting || meeting.cancelled) return { suppress: "cancelled" };
return { say: `Meeting ${meeting.title} is at ${meeting.time}.` };
},{ say } replaces the event's content for the turn. { suppress } finishes the event without the turn seeing it. Throwing withholds it: the turn does not see it, it is not acknowledged, and the runtime delivers it again after a delay that doubles from one second to five minutes. A voice call uses voiceCall({ events: { prepare } }) instead.
Give workers different execution budgets
A worker's run is independent of the run that delegated to it. A parent can finish a request while its worker continues, and a later question or result can wake the originating parent conversation. You can choose different models and run limits for each responsibility:
// On the worker's defineAgent:
runTimeoutMs: 4 * 60 * 60 * 1000,
maxConcurrentRuns: 1runTimeoutMs is the run deadline; the default is five minutes. maxConcurrentRuns limits simultaneous runs of that definition. Plan the runtime's execution.maxConcurrent alongside those limits so background work leaves capacity for responsive agents.
A durable identity does not give a worker an unlimited run or a scheduling priority. These settings control time and capacity; they do not guarantee latency or preempt a slow tool.
What survives a restart
The shared journal retains accepted direct messages, delegated task deliveries, questions, replies and results until a successful receiving run reports consumption. Startup reconciles completed-run receipts and replays unfinished delivery to the stored target. A long live run may have heard an event while its journal receipt remains pending until that run successfully closes.
Delivery is at least once. A crash after a side effect or transcript write but before acknowledgment can repeat work. Use stable event IDs for idempotent external effects. Recovery starts a new execution of pending work; it does not restore a JavaScript stack, arbitrary tool checkpoints or a provider session.
| State | Persistence boundary |
|---|---|
| Instances, ownership, conversation records, events and receipts | The application's FoundryDataAdapter. Use FileFoundryDataAdapter or a persistent adapter implementing the event-journal methods. |
| Message history | The application conversationStore or agent store. An instance record alone does not save native transcripts. |
| Structured memory | The selected memory adapters, with the intended parent or child namespaces. |
| Files and external application data | The configured working-environment persistence or application-owned storage. |
The in-memory adapter does not survive a process restart. Completed and expired event IDs remain as deduplication receipts until owned-data purge; there is no automatic receipt expiry. Transcript writes and journal acknowledgments are separate transactions. Run one controller per data store, and read persistence before deploying.
Inspect, archive, resume and purge
The worker's data belongs to its ownership relationship, so lifecycle operations address the parent and slot. The client exposes these operations on the same handle used for messaging:
const worker = client.subagent(parentId, "analysis");
const state = await worker.data(); // existing instance, conversations, events, children and owned records
await worker.archive(); // stop work and retain its data
await worker.resume(); // activate this worker and recover pending events
await worker.archive();
await worker.purge(); // remove owned data after explicit cleanupArchival marks the worker inactive, archives its existing descendants, pauses schedules and stops queued/running work. Messages, delegation and direct runs are rejected while it is archived. Pending events and private data remain available for inspection or recovery.
Resume activates the selected worker and recovers its pending events. Descendants and paused schedules need explicit resumption. Removing a slot from code detaches its existing worker; it does not erase its records. You can still inspect and archive that data.
Purge requires an archived worker, no running work, already-purged descendants and an explicit idempotent cleanup hook on the slot. The hook deletes private transcripts, memory and external files through your application's adapters. Hook failure leaves the archived Foundry records available for a retry; keep the declaration until cleanup finishes. A no-op hook is appropriate only when no application-owned private data exists.
The bundled adapters then remove owned conversations, event records and deduplication receipts, pending deliveries, activations, VFS snapshots, tasks, inbox items and scoped environment data. Shared parent memory, shared workspace entries and app accounts are preserved. Copies already received in another agent's history, execution logs, observability and third-party records have separate retention.
A minimal tombstone keeps the worker's ownership and provisioning identity. Old retries cannot silently recreate a purged worker. Declare a new slot name when you intentionally want a replacement.
A complete worked example
The realtime front and durable researcher walkthrough puts these primitives together in a runnable project. It includes persistent stores, memory and app inheritance, direct messaging, delegation, delivery policies and restart verification. It is one application of durable workers; the ownership and delivery model above applies regardless of how your agents receive work.
Continue with definitions and instances, applications, task delegation, or the API reference.