Next.js integration
Keep your agents in the same repository as the Next.js app that uses them. The agents live under foundry/ and run in their own Foundry process; your route handlers call that process through a typed HTTP client, so nothing from the agent graph is bundled into the app.
Add Foundry to an app
cd my-next-app
npx glove-foundry init . --target nextjs--target nextjs is optional: a directory with a next.config.(js|mjs|ts|cjs) file or a next dependency is detected. --template picks travel-concierge (default; agent concierge) or minimal (agent assistant). Existing files are never overwritten. The command writes:
| Path | Contents |
|---|---|
foundry/agents/<agent>/** | The template’s agent and its convention files |
foundry/foundry.application.ts | Data adapter, accounts and routes (in-memory to start) |
foundry/lib/** | Template helpers (the travel concierge’s demo model) |
foundry/package.json | { "type": "module", "engines": { "node": ">=22" } }, scoping ESM to the agents |
foundry/README.md | How to run both processes |
foundry.config.mts | agentsDir: "foundry/agents", applicationFile: "foundry/foundry.application.ts", port 4141 |
foundry.env.example | Provider variables (OPENROUTER_API_KEY, OPENROUTER_MODEL) |
lib/foundry.ts | The typed client your app imports |
app/api/<agent>/route.ts | An example route handler |
.foundry/routes.d.ts | Generated route types; rewritten on every foundry:dev/foundry:start |
package.json (merged) | Adds foundry:dev (glove foundry dev) and foundry:start (glove foundry start) unless scripts with those names exist, plus the dependencies below |
Dependencies are set, not merged: glove-foundry, glove-core, effect and zod (plus glove-js, glove-memory and glove-working-environment for the travel concierge) get the exact ranges this Foundry was built against, replacing any range your app already had. An app on zod@3 is moved to zod@4. Review the diff before installing. No tsconfig, ESLint config or .gitignore changes are made; ignore .foundry/ and any data directory yourself.
Why the runtime is a separate process
- It owns durable state and timers: instances, conversations, schedules and sleeping runs should not restart when a React component hot-reloads.
- It starts its own execution daemon and one Node process per agent run, which does not fit a route handler’s lifecycle.
- The app only talks HTTP.
glove-foundry/clienthas no runtime imports (it needs onlyfetch), andFoundryRoutesis a type-only import erased at build time. - Next.js apps are usually CommonJS packages. The nested
foundry/package.jsonmakes the agent tree ESM, and the.mtsconfig is ESM whatever the root says, so Foundry’s ESM-only exports load correctly.
Run both in development
cp foundry.env.example .env.local # or merge into your existing .env.local
pnpm install
pnpm foundry:dev # terminal 1: Foundry runtime + inspector on http://127.0.0.1:4141
pnpm dev # terminal 2: Next.js on http://localhost:3000Both processes read the app root’s .env.local. Foundry also reads .env first and never overwrites a variable that is already set. foundry:dev restarts when a file under foundry/agents/, foundry.config.mts, .env or .env.local changes. It watches the project root non-recursively, so restart it yourself after editing foundry/foundry.application.ts or foundry/lib/. Open the inspector at http://127.0.0.1:4141 to watch runs your routes start (see Inspector & WebChat).
The generated client and route
/**
* The Next.js app talks to the Foundry runtime over HTTP, so nothing from
* the agent graph is bundled into your app. `FoundryRoutes` is a type-only
* import, erased at build time, and it is regenerated on every dev run — so
* agent("concierge") is checked against the files that actually exist.
*/
import { createFoundryClient } from "glove-foundry/client";
import type { FoundryRoutes } from "../.foundry/routes.js";
export const foundry = createFoundryClient<FoundryRoutes>({
baseUrl: process.env.FOUNDRY_URL ?? "http://127.0.0.1:4141",
});
// The agents live in foundry/agents. Start the runtime with
// `foundry:dev`; it serves the inspector at http://127.0.0.1:4141./**
* POST /api/concierge — send a message to the agent and wait for its result.
*
* This runs on the server, so the Foundry runtime never has to be public.
* For a streaming UI, subscribe to the runtime's /api/events instead of
* awaiting run.wait().
*/
import { foundry } from "../../../lib/foundry.js";
export async function POST(request: Request) {
const { message, workspaceId = "default" } = (await request.json()) as {
message: string;
workspaceId?: string;
};
// A real app reuses one instance per user rather than creating one per call.
const agent = await foundry.agent("concierge").create({ workspaceId });
const conversation = await foundry.createConversation(agent.id);
const run = await foundry.send(agent.id, conversation.id, message);
const completed = await run.wait();
return Response.json({ runId: run.id, output: completed.output });
}foundry.agent(route) only accepts routes that exist in .foundry/routes.d.ts. run.wait() polls every 150 ms, returns once the run is completed, failed or cancelled, and throws after 60 s by default. The agent’s answer is completed.output.value.
One instance and conversation per user
The example creates a new instance and conversation on every request, so nothing is remembered. Give each user a stable instance and reuse its conversation:
import { foundry } from "./foundry.js";
/** One durable instance and conversation per user. Cache the ids in your user table. */
export async function supportSessionFor(userId: string) {
const id = `support-${userId}`;
const agent =
(await foundry.agentInstances("support")).find((item) => item.id === id) ??
(await foundry.agent("support").create({ id, workspaceId: "support", context: { userId } }));
const [first] = await foundry.conversations(agent.id);
const conversation = first ?? (await foundry.createConversation(agent.id, { title: "Support" }));
return { agent, conversation };
}
// your code: resolve the signed-in user from your session or auth library.
export async function currentUserId(request: Request): Promise<string | null> {
return request.headers.get("x-demo-user");
}import { foundry } from "../../../lib/foundry.js";
import { currentUserId, supportSessionFor } from "../../../lib/support.js";
export async function POST(request: Request) {
const userId = await currentUserId(request);
if (!userId) return Response.json({ error: "Sign in first." }, { status: 401 });
const { message } = (await request.json()) as { message: string };
const { agent, conversation } = await supportSessionFor(userId);
const run = await foundry.send(agent.id, conversation.id, message);
// Stop polling if the browser goes away; the run itself keeps going.
const settled = await run.wait({ timeoutMs: 120_000, signal: request.signal });
if (settled.status !== "completed") {
return Response.json({ runId: run.id, error: settled.error ?? settled.status }, { status: 502 });
}
return Response.json({ runId: run.id, reply: settled.output?.value });
}Multi-turn memory also needs a durable conversation store: the templates’ MemoryStore gives every run an empty history. See Persistence.
Streaming replies
Foundry’s /v1/chat/completions endpoint streams OpenAI-style chunks when stream: true. A route handler can forward that stream as-is:
import { currentUserId, supportSessionFor } from "../../../../lib/support.js";
const FOUNDRY_URL = process.env.FOUNDRY_URL ?? "http://127.0.0.1:4141";
export async function POST(request: Request) {
const userId = await currentUserId(request);
if (!userId) return Response.json({ error: "Sign in first." }, { status: 401 });
const { message } = (await request.json()) as { message: string };
const { agent } = await supportSessionFor(userId);
const upstream = await fetch(`${FOUNDRY_URL}/v1/chat/completions`, {
method: "POST",
headers: {
"content-type": "application/json",
...(process.env.FOUNDRY_API_TOKEN ? { authorization: `Bearer ${process.env.FOUNDRY_API_TOKEN}` } : {}),
},
body: JSON.stringify({
model: agent.id, // an instance id
stream: true,
user: userId, // same user → same durable conversation on that instance
messages: [{ role: "user", content: message }],
}),
signal: request.signal,
});
if (!upstream.ok || !upstream.body) {
return new Response(await upstream.text(), { status: upstream.status });
}
return new Response(upstream.body, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
"x-foundry-conversation-id": upstream.headers.get("x-foundry-conversation-id") ?? "",
},
});
}/** Browser helper: read the OpenAI-style chunks your stream route forwards. */
export async function streamReply(message: string, onText: (text: string) => void) {
const response = await fetch("/api/support/stream", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ message }),
});
if (!response.ok || !response.body) throw new Error(await response.text());
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) return;
buffer += value;
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const event of events) {
const data = event.replace(/^data: /, "");
if (data === "[DONE]") return;
const chunk = JSON.parse(data) as { choices: Array<{ delta: { content?: string } }> };
const text = chunk.choices[0]?.delta.content;
if (text) onText(text);
}
}
}- Text arrives as it is generated when the agent’s model adapter streams (for example
createAdapter({ …, stream: true })); otherwise the final answer arrives as one chunk. A failed run ends the stream with aFoundry error: …chunk. modelis an instance id, or a definition id when exactly one instance of it exists. Chat Completions conversations are keyed byuser(orconversation_id/ thex-foundry-conversation-idheader) into anopenai-…conversation on that instance, separate from conversations you create withcreateConversation. See OpenAI-compatible API.- To stream a native run instead, call
foundry.send()and forwardGET /v1/runs/:id/eventswithAccept: text/event-stream; that stream ends when the run does and carriesagent.text_deltaevents (see Client & HTTP API).
Production: FOUNDRY_URL and credentials
Deploy the Foundry runtime as its own service (see the container recipe), then set FOUNDRY_URL in the Next.js server environment. Keep it server-only: never prefix it with NEXT_PUBLIC_, and never call Foundry from the browser. When the runtime binds a non-loopback address it requires requestAuthorization, so send credentials from the client:
import { createFoundryClient } from "glove-foundry/client";
import type { FoundryRoutes } from "../.foundry/routes.js";
export const foundry = createFoundryClient<FoundryRoutes>({
baseUrl: process.env.FOUNDRY_URL ?? "http://127.0.0.1:4141",
// Only needed when the runtime enforces requestAuthorization.
...(process.env.FOUNDRY_API_TOKEN
? {
authorization: {
identifier: "next-server",
headers: () => ({ authorization: `Bearer ${process.env.FOUNDRY_API_TOKEN}` }),
},
}
: {}),
});The headers function runs on every request, so a rotated token is picked up without a restart. If your host caps route-handler duration, return the runId right away and poll foundry.getRun(runId) or stream instead of waiting on run.wait().
Foundry behind Next.js vs glove-next alone
glove-next’s createChatHandler is a streaming model proxy for glove-react: the agent loop, its tools and its display stack run in the browser, and the route only forwards model calls. With Foundry, the agent runs on your server as a durable instance and the route is a thin client.
| Choose | When |
|---|---|
glove-react + createChatHandler | A copilot that lives in one page, with tools that render React UI or act on client state, and no server-side history or background work. |
| Foundry behind Next.js | Agents that must remember users across sessions, run on schedules or sleep, receive webhooks or messages through transmissions, ask for approvals, use server-only credentials, or be inspected and steered by operators. |
One app can use both for different features. New to Foundry? Start with the tutorial.