Build agents / defineAgent & assembly

defineAgent and per-run assembly

defineAgent describes how to build a Glove agent for one run. Almost every field can be a function of the instance, the conversation and the new message, so one definition can serve many customers with different prompts, models and tools — without branching inside the prompt.

A complete agent in one file

Save this as agents/support/agent.ts and the route support exists. Every instance of it gets the lookup_order tool.

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

export default defineAgent({
  description: "Answers customer questions about orders",
  store: ({ conversationId }) => FileStore.open(conversationId),
  model: () =>
    createAdapter({
      provider: "openrouter",
      model: process.env.OPENROUTER_MODEL ?? "openai/gpt-4.1-mini",
      stream: true,
    }),
  systemPrompt: "You are a concise, friendly customer-support agent.",
  tools: [
    {
      name: "lookup_order",
      description: "Look up an order by its id",
      inputSchema: z.object({ orderId: z.string() }),
      async do({ orderId }) {
        // your code: query the order system
        return { status: "success" as const, data: { orderId, status: "shipped" } };
      },
    },
  ],
});

defineAgent validates the definition when the module loads:

  • It must define model, run or spawn.
  • Defining model requires systemPrompt.
  • inboxes must be a function.
  • input and output keys are rejected: requests and results use Foundry's fixed contracts. Use request.payload or defineCall for typed data.
!

Each run executes in its own agent job subprocess. A MemoryStore, an in-memory memory adapter or a module-level variable therefore does not carry over to the next run. Return a durable StoreAdapter from store when history must survive between turns; see Persistence.

The value-or-resolver rule

Lazy fields have the type FoundryResolver<T>:

glove-foundry (types)typescript
type Resolvable<T> = T | Promise<T> | Effect.Effect<T, unknown, never>;

type FoundryResolver<T, TInput> =
  | T
  | ((agent: FoundryAgentDefinition, ctx: AgentAssemblyContext<TInput>) => Resolvable<T>);
  • A literal is used as is for every run. Any function is treated as a resolver and called once per run.
  • A resolver may return the value, a Promise, or an Effect with no requirements (Foundry runs it with Effect.runPromise). A literal cannot be a Promise.
  • The first argument is the frozen definition itself (agent.id, agent.description), not a Glove instance.
  • Resolvers in the same assembly step run concurrently and see the same read-only context; do not rely on order between them. A thrown error fails the run.
  • store, inboxes, mcpAdapter, configure, build, spawn and run have their own signatures, listed below.

Every field

Identity, catalogue and adapters

FieldType (resolved value)LazyPurpose
descriptionstring (required)noShown in the manifest and inspector; default mesh identity description.
tagsreadonly string[]noLabels copied into the manifest (GET /api/agents).
componentscomposeAgent(...)noCatalogue of shared tools, apps, MCP servers, memory profiles, layers and subscribers this definition may use. Nothing in it mounts by itself.
store({ definitionId, agentId, conversationId, workspaceId }) => StoreAdapter | Promiseown signatureConversation store for history. Falls back to the application's conversationStore; with neither, history is empty.
mcpAdapter(ctx) => Effect<McpAdapter>per runAuth headers, tokens and stdio env for installed MCP servers. Required once any MCP is installed.
accountSessions{ identifier, withSession(request, use) }per runGives installers withAccountSession for installations that selected an account.

Glove configuration

FieldType (resolved value)LazyPurpose
modelModelAdapteryesRequired unless run or spawn is defined.
systemPromptstringyesRequired whenever model is defined.
displayManagerDisplayManagerAdapteryesDefault is Foundry's approval display manager, which turns permission prompts into approval records. Overriding it sends those prompts to your display manager instead.
serverModebooleannoGlove serverMode. Foundry's default is true.
maxRetriesnumbernoTool execution retries (Glove default 3).
maxConsecutiveErrorsnumbernoPassed through to Glove's maxConsecutiveErrors option.
compactionLimitnumberyesContext tokens before compaction (Glove default 100,000).
compactionInstructionsstringyesDefault: “Preserve goals, decisions, unresolved work, tool results, and pending inbox items.”
maxTurnsnumberyesModel turns allowed per request (Glove default 120).
enableToolResultSummarybooleannoSummarize older tool results in the prompt (Glove default false).

Mounted surfaces

FieldType (resolved value)LazyPurpose
toolsGloveFoldArgs[]yesAlways-on tools for every instance.
hooks{ name, handler: HookHandler }[]yesGlove hooks triggered by /name directives.
skillsDefineSkillArgs[]yesGlove skills.
subagentsDefineSubAgentArgs[] (defineSubagent)yesNested delegates; see Multi-agent.
memory(defineMemory | configureMemory(...))[]yesMemory profiles to mount. Each must also be in components.
goals / facts / formsdefineGoals / defineFacts / defineFormsyesGuidance runners; handles appear on the execution context.
contextProvidersRuntimeContextProvider[]yesTransient context re-read before each model iteration.
inboxes(agent, ctx) => InboxItem[]must be a functionLoads Glove inbox items into the store. Requires an inbox-capable store.
subscribers(defineSubscriber | SubscriberAdapter)[]yesEvent observers. Foundry subscribers must be in components; plain adapters need not be.
layers(defineLayer | configureLayer(...))[]yesSetup functions with native Glove access. Must be in components.
callsdefineCall(...)[]yesTyped functions, exposed as tools unless exposeToAgent: false; callable with ctx.invoke.
schedulesdefineSchedule(...)[]yesDesired recurring work, reconciled into activations every run.
playbookscomposePlaybook(...)[]yesInbound-event policy persisted on the instance. Omit to keep the instance's own playbooks.
mesh{ adapter: MeshAdapter, identity? }yesPeer messaging via glove-mesh.
workingEnvironmentdefineWorkingEnvironment(...)yesPersistent VFS and named scripts.
repldefineRepl(...)yesOne JavaScript, Python or Lisp REPL, optionally with programmatic tool calling.

Installable shared tools, apps and MCP servers are not fields: they live in components and mount only when the instance installs them. See Tools, MCP & memory. Guidance fields are covered in Goals, facts & forms, schedules and playbooks in Playbooks, schedules & sleep, and workingEnvironment/repl in Working environments.

Lifecycle hooks

FieldType (resolved value)LazyPurpose
configure(agent: IGloveRunnable, ctx: FoundryExecutionContext) => Resolvable<void>hookMount anything onto the assembled agent; register teardown with ctx.onCleanup.
build(agent, ctx: AgentAssemblyContext) => Resolvable<IGloveRunnable | undefined>hookWrap or replace the runnable. Returning undefined keeps it.
spawn(agent, ctx: FoundryExecutionContext, message) => Resolvable<unknown>hookBuild a custom execution for one message. A returned object with processRequest() or run(message, signal) is executed; any other value is the result.
run(agent, ctx: AgentHandlerContext) => Resolvable<unknown>hookOwn the whole execution. Call ctx.defaultRun() for the normal Glove loop.

id and handler still type-check but are deprecated: the file path owns identity, and run replaces handler. An agent.ts may also use named exports (export const description, export const run, …) instead of a default defineAgent.

The assembly context

Every resolver and build receive an AgentAssemblyContext:

FieldTypeNotes
definitionIdstringThe route, e.g. support
agentIdstringThe instance id
conversationIdstring
workspaceIdstring
runIdstring
name / modestring / "agent"Internal execution name (foundry_support) and mode
requestFoundryRequest{ agentId, conversationId, workspaceId, message, payload?, context?, source? }; input is the same object
agentInstanceAgentInstancePersisted record: context, installations, playbooks…
conversationConversationPersisted record: title, context…
activationsFoundryActivationRecord[]This instance's schedules and sleeps
dataFoundryDataAdapterThe application's adapter, constructed inside the run process
messageMessageNative Glove user message for this turn, including media parts
messageInputstring | ContentPart[]Exactly what processRequest will receive
messageTextstringText projection of message
historyMessage[]Stored messages before this turn ([] without a store)
messagesMessage[]history plus message
installationsAgentInstallation[]{ kind, id, config?, accountId? } records from the instance
storeStoreAdapter | nullThe resolved store
subscriberSubscriberAdapterFoundry's event forwarder
controls{ signal, emit, commands }Abort signal and custom-event emitter

The execution context

configure, spawn, run and call handlers receive a FoundryExecutionContext instead. It shares the identity and message fields (definitionId through messages, request, installations) and adds:

FieldWhat it is
gloveThe assembled runnable (after build, in run/spawn)
signal, emitRun abort signal and custom-event emitter
onCleanup(fn)Register run-scoped teardown; runs in reverse order even if assembly fails
invoke(name, input)Call one of this run's calls; input and output are validated
workingEnvironment, vfs, replPresent when those fields are configured
goals, facts, formsRunner handles when guidance is configured
defaultRun(message?), spawn(message?)run only: the standard loop, or your spawn path
!

The execution context does not carry agentInstance, conversation, activations, data, store or controls. Read instance data in a resolver (a calls resolver can close over it, as below), and use ctx.emit instead of controls.emit.

Assembly order for one run

Before a run starts, the host reloads the instance and conversation from the data adapter, checks they belong together and share a workspace, and enqueues the run. Inside the run process Foundry then:

  1. Loads foundry.application.ts, calls store (or the application's conversationStore), reads history, and builds the assembly context.
  2. Resolves model, systemPrompt, displayManager, compactionLimit, compactionInstructions and maxTurns concurrently, then constructs the Glove agent.
  3. Resolves tools, hooks, skills, subagents, memory, inboxes, layers, subscribers, calls, playbooks, schedules, mesh, workingEnvironment and repl concurrently.
  4. Reconciles schedules (every run) and playbooks (when the field is defined) onto the instance.
  5. Folds the built-in glove_foundry_* tools, then outbound-transmission tools of installed apps, then your tools, hooks, skills and subagents.
  6. Mounts the working environment and REPL. A REPL with programmaticTools waits until step 12.
  7. Resolves facts, goals, forms and contextProviders and mounts them.
  8. Registers calls, folding each as a tool unless exposeToAgent: false.
  9. Mounts subscribers, then runs each layer's setup.
  10. Mounts the instance's installations — shared tools and app contributions in installation order, then every installed MCP server through mcpAdapter — followed by memory profiles, inbox items and mesh.
  11. Calls configure(agent, ctx).
  12. Mounts the programmatic REPL, so it sees every tool mounted so far.
  13. Calls build(agent, ctx); its return value (or the same agent) is what runs.
  14. Executes run if defined, otherwise spawn, otherwise Glove's processRequest with the inbound message.
  15. Finishes: a run that called sleep ends suspended; onCleanup callbacks run in reverse, then layer and subscriber teardown.

Each resolver emits foundry.assembly.resolve.start/complete events, so slow assembly is visible in the run trace in the inspector.

Message-dependent assembly

The same definition picks a vision model only when an image is attached, writes its prompt from instance data, and exposes a refund tool only to instances whose context allows it — and only on turns that mention refunds.

agents/support/agent.tstypescript
import { type GloveFoldArgs } from "glove-core";
import { createAdapter } from "glove-core/models/providers";
import { Effect } from "effect";
import { defineAgent } from "glove-foundry";
import { z } from "zod";
import { FileStore } from "../../lib/file-store.js"; // durable transcript — see Persistence

const lookupOrder: GloveFoldArgs<{ orderId: string }> = {
  name: "lookup_order",
  description: "Look up an order by its id",
  inputSchema: z.object({ orderId: z.string() }),
  async do({ orderId }) {
    return { status: "success", data: { orderId, status: "shipped" } };
  },
};

const issueRefund: GloveFoldArgs<{ orderId: string; amountCents: number }> = {
  name: "issue_refund",
  description: "Refund part or all of an order",
  inputSchema: z.object({ orderId: z.string(), amountCents: z.number().int().positive() }),
  requiresPermission: true, // pauses the run for an approval
  async do({ orderId, amountCents }) {
    return { status: "success", data: { orderId, refundedCents: amountCents } };
  },
};

export default defineAgent({
  description: "Answers customer questions about orders",
  store: ({ conversationId }) => FileStore.open(conversationId),

  model: (_agent, { message }) => {
    const hasImage = message.content?.some((part) => part.type === "image") ?? false;
    return createAdapter({
      provider: "openrouter",
      model: hasImage ? "openai/gpt-4.1" : "openai/gpt-4.1-mini",
      stream: true,
    });
  },

  systemPrompt: (_agent, { agentInstance, conversation, history }) =>
    [
      `You are the support agent for ${String(agentInstance.context.brand ?? "our store")}.`,
      `Conversation "${conversation.title ?? conversation.id}" has ${history.length} earlier messages.`,
    ].join("\n"),

  tools: (_agent, { agentInstance, messageText, controls }) => {
    const tools: GloveFoldArgs<any>[] = [lookupOrder];
    if (agentInstance.context.canRefund === true && /refund/i.test(messageText)) {
      tools.push(issueRefund);
    }
    // Custom events land in the run trace as agent.support.tools.selected
    controls.emit({ type: "support.tools.selected", data: { names: tools.map((t) => t.name) } });
    return tools;
  },

  // Resolvers may return a Promise or an Effect.
  compactionLimit: (_agent, { history }) =>
    Effect.succeed(history.length > 200 ? 60_000 : 100_000),
  maxTurns: 25,
});

agentInstance.context is whatever you stored when creating the instance ({ brand: "Acme", canRefund: true }); change it with configureAgent and the next run assembles differently. See Definitions & instances. Data passed to emit is retained in observability as is, so never emit secrets.

configure, calls and a custom run

configure mounts anything a field cannot express and registers its teardown. run takes over execution: here a typed payload takes a deterministic fast path with no model call, and everything else goes through the normal loop with an enriched user turn.

agents/support/agent.tstypescript
import { createAdapter } from "glove-core/models/providers";
import { defineAgent, defineCall } from "glove-foundry";
import { z } from "zod";
import { FileStore } from "../../lib/file-store.js"; // durable transcript — see Persistence

// your code: a client that holds a connection open for one run
function connectCrm(workspaceId: string) {
  return {
    async findCustomer(email: string) { return { email, plan: "pro", workspaceId }; },
    async close() {},
  };
}

const statusRequest = z.object({ kind: z.literal("order-status"), orderId: z.string() });

export default defineAgent({
  description: "Support agent with a deterministic fast path",
  store: ({ conversationId }) => FileStore.open(conversationId),
  model: () => createAdapter({ provider: "openrouter", model: "openai/gpt-4.1-mini", stream: true }),
  systemPrompt: "You are a concise customer-support agent.",

  calls: (_agent, { agentInstance }) => [
    defineCall({
      name: "order_status",
      description: "Return the status of one order",
      input: z.object({ orderId: z.string() }),
      output: z.object({ orderId: z.string(), status: z.string(), region: z.string() }),
      // Closes over assembly-time instance data.
      handler: ({ orderId }) => ({
        orderId,
        status: "shipped",
        region: String(agentInstance.context.region ?? "us"),
      }),
    }),
  ],

  configure: async (agent, ctx) => {
    const crm = connectCrm(ctx.workspaceId);
    ctx.onCleanup(() => crm.close()); // register right after acquiring
    agent.fold({
      name: "find_customer",
      description: "Find a customer in the CRM by email",
      inputSchema: z.object({ email: z.string() }),
      async do({ email }) {
        return { status: "success", data: await crm.findCustomer(email) };
      },
    });
    agent.addContextProvider(() => `Current time: ${new Date().toISOString()}`);
  },

  run: async (_agent, ctx) => {
    const status = statusRequest.safeParse(ctx.request.payload);
    if (status.success) {
      ctx.emit({ type: "support.fast-path", data: { orderId: status.data.orderId } });
      return ctx.invoke("order_status", { orderId: status.data.orderId });
    }
    return ctx.defaultRun([
      { type: "text", text: `${ctx.messageText}\n\n(arrived via ${ctx.request.source?.kind ?? "direct"})` },
    ]);
  },
});
  • defaultRun(message?) runs Glove's processRequest; without an argument it uses the inbound message. Its return value is the final assistant text.
  • Whatever run returns becomes the run's output (JSON-serialized). A fast path that skips defaultRun writes nothing to the conversation store.
  • Inside run, ctx.spawn(message?) calls your spawn hook when one is defined and falls back to defaultRun.
  • onCleanup callbacks run after the run finishes, fails or is cancelled. A failing callback does not stop the others; the errors are reported together.

build

build(agent, ctx) runs after everything is mounted and receives the assembly context. Return a wrapped or different IGloveRunnable to change what executes, or return nothing to keep the assembled agent. Prefer configure for mounting; reach for build only when you need to replace the runnable itself.

Splitting the definition across files

Larger agents keep agent.ts small by importing colocated pieces — tools/*.tool.ts, apps/*.app.ts, mcp/*.mcp.ts, memory/*.memory.ts, layers/*.layer.ts, subscribers/*.subscriber.ts — and passing them to composeAgent(...) in components. File names become ids; see Project structure for the conventions and Tools, MCP & memory for how each piece is mounted.