From glove foundry dev to production
A Foundry agent that works on your laptop is three changes away from production: keep its state on a disk, put a check in front of its API, and point its model at a gateway. This walkthrough makes those changes to the minimal starter and deploys it on Kuiper, with no Dockerfile and no config file.
Start from the minimal template
npx glove-foundry init assistant --template minimal
cd assistant
pnpm install
pnpm devOpen http://127.0.0.1:4141, start a run, and ask the assistant what time it is. The inspector shows the run, the model call and the current_time tool call. That is a whole agent system already: an instance, a conversation, a run queue, an HTTP API and an inspector.
Two files matter for production. foundry.application.ts holds the process-level setup, and agents/assistant/agent.ts is the agent. The file path is the agent's id.
1. Keep state on a disk you choose
Foundry runs every message in a fresh process. Anything the agent needs on the next message has to be stored outside that process. The starter already does this under .data/: FileFoundryDataAdapter keeps instances, conversations, schedules and workspace data, and lib/file-store.ts keeps each conversation's transcript.
The only change is to make the directory configurable, so the same code writes to .data/ on your laptop and to Kuiper's persistent disk in production:
import { fileURLToPath } from "node:url";
/** The persistent disk in production; .data/ beside the project locally. */
export const dataDir = process.env.AGENT_DATA_DIR
?? fileURLToPath(new URL("../.data/", import.meta.url));Point both adapters at it. The transcript store takes a directory as its second argument:
store: ({ conversationId }) =>
FileStore.open(conversationId, join(dataDir, "transcripts")),If the agent uses glove-memory or a working environment, put their files under the same directory. The four things to persist separately are runtime data, transcripts, memory and the VFS; see Persistence.
2. Put a check in front of the API
Locally Foundry listens on loopback. On a public interface it refuses to start unless foundry.application.ts defines requestAuthorization. Without it, the API, the inspector and the event streams would be open to anyone.
The smallest useful check accepts one bearer token and lets health checks through:
import { timingSafeEqual } from "node:crypto";
import { join } from "node:path";
import { Effect } from "effect";
import { FileFoundryDataAdapter, defineApplication } from "glove-foundry";
import { dataDir } from "./lib/paths.js";
const token = process.env.FOUNDRY_TOKEN;
function bearerMatches(authorization?: string): boolean {
if (!token || !authorization?.startsWith("Bearer ")) return false;
const given = Buffer.from(authorization.slice(7));
const expected = Buffer.from(token);
return given.length === expected.length && timingSafeEqual(given, expected);
}
export const data = new FileFoundryDataAdapter({ file: join(dataDir, "foundry.json") });
export default defineApplication({
name: "assistant",
data,
accounts: [],
routes: [],
bindings: [],
requestAuthorization: {
identifier: "foundry-token",
challenge: "Bearer",
authorize: ({ path, authorization }) =>
Effect.succeed(path === "/health" || bearerMatches(authorization)),
},
});The adapter only returns yes or no. Foundry never keeps the credential, and it never reaches prompts, events or the inspector. For a browser login to the inspector, add an HTTP Basic branch; Glove Foundry units has the version that accepts both.
3. Call models through a gateway
The starter reads an OpenRouter key from the environment. In production you would rather not hand provider keys to the running process at all. Kuiper's model gateway serves each provider's own API at one address, sends each call to your organisation's provider key, and records every call with its tokens and estimated cost. Glove's createAdapter only needs a different base URL and key, and @kuiper-sh/sdk supplies both:
pnpm add @kuiper-sh/sdkimport { join } from "node:path";
import { Kuiper } from "@kuiper-sh/sdk";
import { createAdapter } from "glove-core/models/providers";
import { defineAgent } from "glove-foundry";
import { z } from "zod";
import { FileStore } from "../../lib/file-store.js";
import { dataDir } from "../../lib/paths.js";
export default defineAgent({
description: "A general-purpose Glove assistant",
// new Kuiper() reads its gateway key from KUIPER_GATEWAY_KEY.
model: () => {
const kuiper = new Kuiper();
return createAdapter({
provider: "anthropic",
model: "claude-sonnet-5-5",
...kuiper.models.glove("anthropic"),
});
},
store: ({ conversationId }) =>
FileStore.open(conversationId, join(dataDir, "transcripts")),
systemPrompt: "You are a precise, practical assistant.",
tools: [{
name: "current_time",
description: "Return the current ISO timestamp",
inputSchema: z.object({}),
async do() {
return { status: "success", data: new Date().toISOString() };
},
}],
});kuiper.models.glove(provider) returns the baseURL and apiKey for that provider: the Anthropic path for anthropic, the shared one for every other provider. A provider's own model names, like claude-… or gpt-…, find your key of that provider. Add the provider key once for the organisation:
kuiper providers add anthropic --key -For local development, make a gateway key for your laptop and put it in .env.local as KUIPER_GATEWAY_KEY. Your laptop and production then take the same code path:
kuiper keys create laptop -p assistant --services models4. Deploy
Kuiper detects Foundry from foundry.config.ts or the glove-foundry dependency. It builds a Node 22 image with your package manager, starts glove foundry start --host 0.0.0.0 --port 4141, gives the unit a public HTTPS address, a health check and a 5 GiB disk at /data.
kuiper projects create assistant
kuiper use assistant
kuiper units create assistant
openssl rand -hex 24 | kuiper secrets set FOUNDRY_TOKEN=-
kuiper env set -u assistant \
'FOUNDRY_TOKEN=${{secrets.FOUNDRY_TOKEN}}' \
'KUIPER_GATEWAY_KEY=${{models.API_KEY}}' \
AGENT_DATA_DIR=/data
kuiper deploy --source . -u assistant --waitThe variable names are the ones this code reads: FOUNDRY_TOKEN by the authorizer, KUIPER_GATEWAY_KEY by the SDK, AGENT_DATA_DIR by lib/paths.ts. Their values are templates. ${{secrets.FOUNDRY_TOKEN}} reads a sealed project secret, which never enters the image and is redacted from logs. ${{models.API_KEY}} is a gateway key made for each running copy of the unit, revoked when that copy stops. Because the unit references models, its key opens the model gateway and nothing else.
The deploy uploads the folder, streams the build log, and prints each step of the rollout until the deployment is live. .env files are never uploaded.
kuiper status -u assistant
kuiper logs -u assistant -f
kuiper outputs -u assistant5. Call it from your app
kuiper outputs lists the unit's PUBLIC_URL. The typed client takes it and a header function:
import { createFoundryClient } from "glove-foundry/client";
import type { FoundryRoutes } from "./.foundry/routes.js";
const foundry = createFoundryClient<FoundryRoutes>({
baseUrl: assistantUrl, // the unit's PUBLIC_URL
authorization: {
identifier: "foundry-token",
headers: () => ({ authorization: `Bearer ${foundryToken}` }),
},
});
const me = await foundry.agent("assistant").create();
const thread = await foundry.createConversation(me.id);
const run = await foundry.send(me.id, thread.id, "What time is it?");
const done = await run.wait();
console.log(done.output?.value);The same instance answers on /v1/chat/completions and /v1/responses, so an OpenAI SDK or an existing chat UI can talk to it with the instance id as the model. A web app deployed as another unit in the same project can reach it privately through ${{assistant.INTERNAL_URL}}.
6. Watch it
Every Foundry unit has an Inspect card, and every project an Agents page. They show runs, conversations, events, approvals, activations and health, read from the app's own API through the node it runs on. The browser never sees the token, and only reads are possible. The authorizer above is what makes that work: Kuiper calls the API with the unit's FOUNDRY_TOKEN.
kuiper models calls --unit assistant
kuiper models usage --by modelThe first lists the unit's model calls, newest first. The second sums calls, tokens and estimated provider cost.
What to know before you rely on it
- One replica. The file adapters are single-host, and a unit with a volume runs one copy, recreated on deploy rather than overlapped. For several hosts, implement
FoundryDataAdapterand aStoreAdapterover a managed Postgres. - Runs time out after five minutes. Foundry's embedded queue is in memory. Long waits belong in
glove_foundry_sleep, which suspends the run and wakes the same conversation later. - Health. Foundry's
/healthsits behind your authorizer. The default check is TCP. Because this authorizer lets/healththrough, an HTTP check works too. - Push to deploy.
kuiper link -u assistant --repo … --branch mainreplacesdeploy --sourceonce the code is in a repository.
What you did not write
| Concern | Without Kuiper | On Kuiper |
|---|---|---|
| Build and start | A Dockerfile, a process manager | Detected from the Foundry project |
| Disk for state | A volume, mounted and backed up | A disk at /data on every Foundry unit |
| Provider keys | Copied into every service | Held by the gateway; units get a per-copy key |
| Secrets | A secrets manager integration | Sealed, bound by template, redacted from logs |
| HTTPS | Certificates and a proxy | An address per unit, custom domains on request |
| Seeing runs in production | Exposing the inspector | Inspect card and Agents page, read-only |
| Model spend | Reconciling provider bills | Every call recorded with tokens and cost |
| Rolling back | Image tags and a runbook | kuiper rollback DEPLOYMENT |