Tutorial: your first agent system
In about half an hour you will build a customer-support agent that looks up orders, keeps separate conversations per customer, schedules its own follow-ups, waits for a human before refunding money, and answers any OpenAI SDK client. Every step ends with something you can see in the inspector or your terminal.
You need Node.js 22 or newer (22.13+ recommended), pnpm (or npm, yarn, bun), and an OpenRouter API key. Commands below use pnpm; with npm, write npm run dev instead of pnpm dev.
1. Scaffold and run
npx glove-foundry init support-desk --template minimal
cd support-desk
cp .env.example .env.local # then set OPENROUTER_API_KEY=... in .env.local
pnpm devThe wizard asks only what you did not pass on the command line (project type, package manager, whether to install). For scripts, add --yes --install. When the runtime is up, the terminal prints:
Glove Foundry
Local: http://127.0.0.1:4141
Agents: 1
Runtime: ready
Types: .foundry/routes.d.tsOpen http://127.0.0.1:4141. Press Start a run, pick the assistant definition, type “What time is it?”, and submit. The inspector creates an instance and a conversation for you, then opens the run: a four-phase spine (Accepted → Assembled → Agent work → Completed) with the model and tool events below it. The Chat page is the built-in WebChat for the same instance.
pnpm dev restarts the runtime whenever a file under agents/, .env, .env.local, foundry.application.ts or foundry.config.ts changes. Keep it running for the whole tutorial.
2. Define the support agent
First, notice what the starter already did to make state survive. Two facts about Foundry make it necessary:
pnpm devrestarts the runtime on every save, so runtime data kept only in memory (MemoryFoundryDataAdapter) would lose every instance, conversation and schedule each time.- Every run executes in its own worker process. A
storethat returnednew MemoryStore(...)would start each turn with an empty history, and the WebChat would have nothing to show.
So foundry.application.ts uses the bundled file adapter, and lib/file-store.ts is a file-backed transcript store. Both write under .data/, which the starter already git-ignores. You don't need to change either; this is the essence of what they do:
import { FileFoundryDataAdapter, defineApplication } from "glove-foundry";
import { fileURLToPath } from "node:url";
// Instances, conversations, installations and schedules, in one JSON file.
export const data = new FileFoundryDataAdapter({
file: fileURLToPath(new URL("./.data/foundry.json", import.meta.url)),
});
export default defineApplication({ name: "support-desk", data });import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { MemoryStore, type Message } from "glove-core";
/**
* A MemoryStore that saves the transcript to a JSON file after every append.
* Each Foundry run executes in its own process, so a plain MemoryStore starts
* empty on every message; this one reloads the conversation first.
* The starter's version also saves turn and token counters, tasks and inbox items.
*/
export class FileStore extends MemoryStore {
private constructor(id: string, private readonly file: string) {
super(id);
}
static async open(id: string, directory = ".data/transcripts"): Promise<FileStore> {
await mkdir(directory, { recursive: true });
const store = new FileStore(id, join(directory, `${encodeURIComponent(id)}.json`));
const saved = await readFile(store.file, "utf8").then(JSON.parse, () => [] as Message[]);
await MemoryStore.prototype.appendMessages.call(store, saved); // restore without re-saving
return store;
}
override async appendMessages(messages: Message[]): Promise<void> {
await super.appendMessages(messages);
const temp = `${this.file}.tmp`;
await writeFile(temp, JSON.stringify(await this.getMessages()));
await rename(temp, this.file);
}
}Now the agent. Its file path is its id: agents/support/agent.ts is the definition support.
import { createAdapter } from "glove-core/models/providers";
import { defineAgent } from "glove-foundry";
import { FileStore } from "../../lib/file-store.js";
export default defineAgent({
description: "Answers Acme Outfitters customers about orders and refunds",
// Called per run with the ids of the conversation being served.
store: ({ conversationId }) => FileStore.open(conversationId),
model: () =>
createAdapter({
provider: "openrouter",
model: process.env.OPENROUTER_MODEL ?? "openai/gpt-4.1-mini",
stream: true,
}),
// A function is resolved per run from the assembly context.
systemPrompt: (_agent, ctx) =>
[
"You are the customer-support agent for Acme Outfitters. Be brief.",
"Never invent order details. If you cannot look an order up, say so.",
`Customer tier: ${String(ctx.agentInstance.context.tier ?? "standard")}.`,
`Installed for this instance: ${ctx.installations.map((item) => `${item.kind}:${item.id}`).join(", ") || "nothing"}.`,
`Earlier messages in this conversation: ${ctx.history.length}.`,
].join("\n"),
});Save, and the runtime restarts with Agents: 2. Every field of defineAgent may be a value or a resolver (agent, ctx) => value; ctx carries the instance (ctx.agentInstance.context), its installations, the current message, the prior history, and the agent, conversation and workspace ids. Press Start a run, choose support and send “Hi, I’m Sam.” Then open Chat, select the new support instance and its conversation, and ask “What’s my name?” — the reply knows, because the transcript now outlives the worker process. (model and systemPrompt must be defined together; the defineAgent reference lists every field.)
3. Add an always-on tool
Tools that every instance should always have go in the tools field. They are plain Glove tools — name, description, inputSchema, do — with no installation step.
import { z } from "zod";
const supportHours = {
name: "support_hours",
description: "Say whether the human support desk is open right now (Mon–Fri, 09:00–17:00 UTC).",
inputSchema: z.object({}),
async do() {
const now = new Date();
const weekday = now.getUTCDay() >= 1 && now.getUTCDay() <= 5;
const open = weekday && now.getUTCHours() >= 9 && now.getUTCHours() < 17;
return { status: "success" as const, data: { open, checkedAt: now.toISOString() } };
},
};
export default defineAgent({
// ...everything from step 2
tools: [supportHours],
});Ask “Is the support desk open right now?”. The run trace shows a support_hours tool call and its result before the reply. tools can also be a resolver, e.g. to return extra tools only for ctx.agentInstance.context.tier === "gold".
4. Add an installable tool
Some capabilities differ per instance: the EU desk must query the EU order system, the US desk the US one. A shared tool is a convention file with a typed config; each instance that installs it supplies its own config.
import { Effect } from "effect";
import { defineSharedTool } from "glove-foundry";
import { z } from "zod";
// your code: replace this with a call to your order system.
const ORDERS: Record<string, { region: "eu" | "us"; status: string; total: number }> = {
"A-1001": { region: "eu", status: "shipped, arriving Thursday", total: 84 },
"A-1002": { region: "eu", status: "processing", total: 129 },
"B-2001": { region: "us", status: "delivered", total: 42 },
};
const lookupOrder = defineSharedTool({
description: "Look up an order in one regional order system",
// Each instance that installs this tool must supply a region.
config: z.object({ region: z.enum(["eu", "us"]) }),
create: ({ config }) =>
Effect.succeed({
name: "lookup_order",
description: `Look up an order in the ${config.region.toUpperCase()} order system by id, e.g. A-1001.`,
inputSchema: z.object({ orderId: z.string() }),
async do({ orderId }: { orderId: string }) {
const order = ORDERS[orderId];
if (!order || order.region !== config.region) {
return { status: "error" as const, data: null, message: `No ${config.region} order "${orderId}".` };
}
return { status: "success" as const, data: { orderId, status: order.status, total: order.total } };
},
}),
});
export default lookupOrder;The filename is the id: tools/lookup-order.tool.ts is the tool lookup-order. Add it to the agent’s catalogue:
import { composeAgent, defineAgent } from "glove-foundry";
import lookupOrder from "./tools/lookup-order.tool.js";
export default defineAgent({
// ...
components: composeAgent(lookupOrder),
});It is in the catalogue, not on the agent
composeAgent only says which installable parts an instance may use. Create an instance and try it:
curl -s -X POST http://127.0.0.1:4141/api/agent-instances \
-H 'content-type: application/json' \
-d '{"definitionId":"support","id":"support-eu","workspaceId":"acme","context":{"tier":"gold"}}'In Chat, open support-eu and ask “Where is order A-1001?”. The agent says it cannot look orders up — its prompt said Installed for this instance: nothing and the run has no lookup_order tool. If a model calls it anyway, the tool result is No tool called lookup_order exists. Available tools: …. The instance page in Agents shows “Nothing installed”.
Install it
An installation is instance data: kind, id and config. Add it over HTTP…
curl -s -X PUT http://127.0.0.1:4141/api/installations \
-H 'content-type: application/json' \
-d '{"agentId":"support-eu","kind":"tool","id":"lookup-order","config":{"region":"eu"}}'…or with the typed client, await foundry.install("support-eu", { kind: "tool", id: "lookup-order", config: { region: "eu" } }), or by passing installations when you create the instance. Ask again: the trace now shows a lookup_order call returning shipped, arriving Thursday, and the instance page lists tool · lookup-order. No restart was needed — assembly reads installations on every run.
To seed an instance from code instead, declare it next to the data adapter:
import {
FileFoundryDataAdapter,
defineAgentInstance,
defineApplication,
install,
} from "glove-foundry";
import support from "./agents/support/agent.js";
import lookupOrder from "./agents/support/tools/lookup-order.tool.js";
// Seeded once: inserted only if .data/foundry.json has no instance with this id.
const supportUs = defineAgentInstance(support, {
id: "support-us",
workspaceId: "acme",
installations: [install(lookupOrder, { region: "us" })], // config is type-checked
});
export const data = new FileFoundryDataAdapter({
file: ".data/foundry.json",
agents: [supportUs],
});
export default defineApplication({ name: "support-desk", data });Installing an id that is not in the definition’s composeAgent catalogue is rejected with Unknown Foundry tool capability "…" (HTTP 400). Config is decoded against the zod schema when a run assembles; a bad config fails that run with Invalid config for tool "lookup-order". Use install() only inside the Foundry project; client applications send the plain { kind, id, config } object. The inspector displays installations but does not edit them.
5. Call it from code
Your application talks to the runtime over HTTP with the typed client. FoundryRoutes is regenerated into .foundry/routes.d.ts on every start, so agent("support") is checked against the agents that exist.
import { createFoundryClient } from "glove-foundry/client";
import type { FoundryRoutes } from "../.foundry/routes.js";
const foundry = createFoundryClient<FoundryRoutes>({ baseUrl: "http://127.0.0.1:4141" });
// One instance = one persisted support identity with its own installations.
const existing = (await foundry.agentInstances("support")).find((item) => item.id === "support-eu");
const agent = existing ?? await foundry.agent("support").create({
id: "support-eu",
workspaceId: "acme",
context: { tier: "gold" },
installations: [{ kind: "tool", id: "lookup-order", config: { region: "eu" } }],
});
// Two independent conversations owned by the same instance.
const alice = await foundry.createConversation(agent.id, { title: "Alice" });
const bob = await foundry.createConversation(agent.id, { title: "Bob" });
async function ask(conversationId: string, text: string): Promise<unknown> {
const run = await foundry.send(agent.id, conversationId, text);
const done = await run.wait({ timeoutMs: 120_000 });
if (done.status !== "completed") throw new Error(done.error ?? `run ${done.status}`);
return done.output?.value;
}
console.log("alice:", await ask(alice.id, "Hi, I'm Alice. Where is order A-1001?"));
console.log("bob: ", await ask(bob.id, "Hi! What's my name?"));
console.log("alice:", await ask(alice.id, "What's my name, and what was my order?"));npx tsx src/support-client.ts
# alice: Order A-1001 has shipped and should arrive Thursday.
# bob: I don't know your name yet — what should I call you?
# alice: You're Alice, and you asked about order A-1001 (shipped, arriving Thursday).Model wording varies; the shape does not. send returns as soon as the run is accepted; run.wait() polls until it is completed, failed or cancelled (default timeout 60 s) and output.value holds the final assistant text. Both conversations appear under the instance in Chat; foundry.conversationTranscript(agent.id, alice.id) returns the stored messages.
6. Let it schedule follow-up work
Your agent can wake itself up. There are two ways, and you get both.
Schedules you declare. A defineSchedule value in the schedules field is desired recurring work for each instance:
import { composeAgent, defineAgent, defineSchedule } from "glove-foundry";
const followUpSweep = defineSchedule({
name: "follow-up-sweep",
description: "Weekday review of anything the agent promised to follow up on",
timing: { kind: "cron", expression: "0 9 * * 1-5", timezone: "UTC" },
message: "Review this conversation for promised follow-ups. Re-check those orders and summarize what changed.",
});
export default defineAgent({
// ...
// An instance can opt out through its own context.
schedules: (_agent, ctx) => (ctx.agentInstance.context.followUps === false ? [] : [followUpSweep]),
});Foundry reconciles schedules while it assembles a run, so an instance’s activation is created on that instance’s next run and delivers into the conversation that run belonged to. Send any message, then open Automations: follow-up-sweep is listed for support-eu. Timing kinds are cron (timezone defaults to UTC), every ("2h"), after ("20m") and at (ISO date). Schedules are values you import — there is no schedules/ file convention.
Work the model schedules itself. Every run also gets Foundry’s built-in tools, including glove_foundry_schedule (one-off or recurring future work), glove_foundry_schedules (list, update, pause, resume, cancel its own) and glove_foundry_sleep (suspend this run and resume the same conversation later). Tell the model when to use them:
"If you promise to check something later, schedule it with glove_foundry_schedule.",Ask “Order A-1002 is still processing — can you check again in 2 minutes?”. The trace shows a call like glove_foundry_schedule({ message: "Re-check order A-1002…", timing: { kind: "after", duration: "2m" } }). The activation appears in Automations; two minutes later a new run with source activation appears in Runs and its answer lands in the same conversation. From code, foundry.activations("acme") lists them. Activations live in the data adapter, so with FileFoundryDataAdapter they survive restarts; they fire only while the runtime is running.
7. Gate a risky tool behind approval
Refunds move money, so a human approves each one. Mark the tool with requiresPermission:
import type { GloveFoldArgs } from "glove-core";
const refundInput = z.object({
orderId: z.string(),
amount: z.number().positive().describe("Amount in USD"),
reason: z.string(),
});
const issueRefund: GloveFoldArgs<z.infer<typeof refundInput>> = {
name: "issue_refund",
description: "Refund all or part of an order. A human approves every refund.",
inputSchema: refundInput,
requiresPermission: true, // or (input) => input.amount > 50
async do({ orderId, amount }) {
// your code: await payments.refund(orderId, amount)
return { status: "success", data: { orderId, refunded: amount } };
},
};
export default defineAgent({
// ...
tools: [supportHours, issueRefund],
});Ask “Order A-1001 arrived broken, please refund the $84.” The run stays running and the inspector’s Overview shows Agents are waiting for a decision with the exact tool input and Deny / Approve this input buttons (the run page shows the same card). From code:
import { createFoundryClient } from "glove-foundry/client";
import type { FoundryRoutes } from "../.foundry/routes.js";
const foundry = createFoundryClient<FoundryRoutes>();
const agentId = "support-eu";
const conversation = await foundry.createConversation(agentId, { title: "Refund" });
const run = await foundry.send(agentId, conversation.id, "Order A-1001 arrived broken. Please refund the full $84.");
// The run pauses at issue_refund. Poll until the approval record appears.
let approval = (await foundry.approvals({ runId: run.id, status: "pending" }))[0];
while (!approval) {
const current = await run.get();
if (current.status !== "pending" && current.status !== "running") {
throw new Error(`run ended as ${current.status} without asking for approval`);
}
await new Promise((resolve) => setTimeout(resolve, 500));
approval = (await foundry.approvals({ runId: run.id, status: "pending" }))[0];
}
console.log(approval.toolName, approval.toolInput, "expires", approval.expiresAt);
await foundry.resolveApproval(approval.id, "approve"); // or "deny"
const done = await run.wait({ timeoutMs: 120_000 });
console.log(done.status, done.output?.value);Approval records expire after 5 minutes and do not survive a runtime restart. Approvals only work through Foundry’s default display manager, so leave displayManager unset on the definition. A denial, an expired request and an unavailable approval channel all fail closed: the tool does not run and the model receives Permission denied for tool "issue_refund". The same operations exist over HTTP as GET /api/approvals?runId=…&status=pending and POST /api/approvals/:id with {"decision":"approve"} (also under /v1/approvals). See Approvals & steering.
8. Talk to it with the OpenAI SDK
Any OpenAI-compatible client can use your agent: Foundry serves /v1/chat/completions, and the “model” is an agent instance id.
pnpm add openaiimport OpenAI from "openai";
const openai = new OpenAI({
baseURL: "http://127.0.0.1:4141/v1",
// The SDK insists on a key. Foundry ignores it on loopback; with
// requestAuthorization configured, your adapter receives it as a Bearer token.
apiKey: "unused-on-loopback",
});
// model = an agent instance id. Send only the new user turn: Foundry keeps the history.
const { data: first, response } = await openai.chat.completions
.create({
model: "support-eu",
messages: [{ role: "user", content: "Hi, where is order A-1001?" }],
})
.withResponse();
console.log(first.choices[0]?.message.content);
// Continue the same durable conversation.
const conversationId = response.headers.get("x-foundry-conversation-id")!;
const followUp = await openai.chat.completions.create(
{ model: "support-eu", messages: [{ role: "user", content: "Thanks. Is the help desk open right now?" }] },
{ headers: { "x-foundry-conversation-id": conversationId } },
);
console.log(followUp.choices[0]?.message.content);
// Streaming uses the same endpoint.
const stream = await openai.chat.completions.create(
{ model: "support-eu", stream: true, messages: [{ role: "user", content: "Summarize what we discussed." }] },
{ headers: { "x-foundry-conversation-id": conversationId } },
);
for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
process.stdout.write("\n");Run it with npx tsx src/openai-client.ts. The runs show up in the inspector like any other, with tool calls executed server-side. What Foundry does with the request:
| Request part | Behavior |
|---|---|
model | An instance id, or a definition id that has exactly one instance. Otherwise 404: Foundry model "…" does not resolve to one agent instance. GET /v1/models lists instances. |
| Conversation key | Header x-foundry-conversation-id, else body conversation_id, else user. Keys already shaped like openai-… are used as-is; others map to a stable openai-<hash> per instance. No key starts a new conversation. |
messages | Only the last user message becomes the new turn. Earlier entries are folded in as context text only when the conversation is new; afterwards Foundry uses its stored history. Roles must be system, user, assistant or tool (a developer message is a 400). |
stream | chat.completion.chunk SSE events, then data: [DONE]. |
| Response | Always carries x-foundry-conversation-id. No usage block. A failed run returns HTTP 502 (non-streaming) or a final Foundry error: … chunk. |
| Other params | tools, temperature and similar are ignored; the definition owns model and tools. |
/v1/responses works the same way for the Responses API. Details in OpenAI-compatible API.
The finished agent
import type { GloveFoldArgs } from "glove-core";
import { createAdapter } from "glove-core/models/providers";
import { composeAgent, defineAgent, defineSchedule } from "glove-foundry";
import { z } from "zod";
import { FileStore } from "../../lib/file-store.js";
import lookupOrder from "./tools/lookup-order.tool.js";
// Step 3 — always on: every instance gets this tool on every run.
const supportHours = {
name: "support_hours",
description: "Say whether the human support desk is open right now (Mon–Fri, 09:00–17:00 UTC).",
inputSchema: z.object({}),
async do() {
const now = new Date();
const weekday = now.getUTCDay() >= 1 && now.getUTCDay() <= 5;
const open = weekday && now.getUTCHours() >= 9 && now.getUTCHours() < 17;
return { status: "success" as const, data: { open, checkedAt: now.toISOString() } };
},
};
// Step 7 — a risky tool. Foundry turns every call into an approval request.
const refundInput = z.object({
orderId: z.string(),
amount: z.number().positive().describe("Amount in USD"),
reason: z.string(),
});
const issueRefund: GloveFoldArgs<z.infer<typeof refundInput>> = {
name: "issue_refund",
description: "Refund all or part of an order. A human approves every refund.",
inputSchema: refundInput,
requiresPermission: true,
async do({ orderId, amount }) {
// your code: await payments.refund(orderId, amount)
return { status: "success", data: { orderId, refunded: amount } };
},
};
// Step 6 — desired recurring work, owned by each instance that runs.
const followUpSweep = defineSchedule({
name: "follow-up-sweep",
description: "Weekday review of anything the agent promised to follow up on",
timing: { kind: "cron", expression: "0 9 * * 1-5", timezone: "UTC" },
message: "Review this conversation for promised follow-ups. Re-check those orders and summarize what changed.",
});
export default defineAgent({
description: "Answers Acme Outfitters customers about orders and refunds",
components: composeAgent(lookupOrder), // step 4: installable, per instance
store: ({ conversationId }) => FileStore.open(conversationId),
model: () =>
createAdapter({
provider: "openrouter",
model: process.env.OPENROUTER_MODEL ?? "openai/gpt-4.1-mini",
stream: true,
}),
systemPrompt: (_agent, ctx) =>
[
"You are the customer-support agent for Acme Outfitters. Be brief.",
"Never invent order details. If you cannot look an order up, say so.",
"If you promise to check something later, schedule it with glove_foundry_schedule.",
`Customer tier: ${String(ctx.agentInstance.context.tier ?? "standard")}.`,
`Installed for this instance: ${ctx.installations.map((item) => `${item.kind}:${item.id}`).join(", ") || "nothing"}.`,
`Earlier messages in this conversation: ${ctx.history.length}.`,
].join("\n"),
tools: [supportHours, issueRefund],
schedules: (_agent, ctx) => (ctx.agentInstance.context.followUps === false ? [] : [followUpSweep]),
});Run pnpm typecheck and pnpm lint; the lint step includes the Foundry ESLint rules.
Where to go next
- Project structure — every file convention and how ids are derived.
- Tools, MCP & memory — MCP servers, memory profiles, layers and typed calls.
- Apps & transmissions — turn the desk into a
helpdeskapp that receives and replies totickets. - Playbooks, schedules & sleep — event-driven work and every built-in agent tool.
- Client & HTTP API — every endpoint,
/v1/runs, steering and event streams. - Persistence — production adapters for runtime data, transcripts, memory and files.
- Deploy & security and the FAQ.