Deploy & security
A Foundry deployment is one Node 22+ process tree: the control process (HTTP API, inspector, instances, schedules) plus a managed execution daemon that runs each agent turn in its own job process. Deploying it means giving that tree durable adapters, an authorization boundary and a health check.
glove foundry start
# from the project root (the directory holding foundry.config.ts)
glove foundry start --host 0.0.0.0 --port 4141| Flag | Default | Meaning |
|---|---|---|
--root <dir> | current directory | Project root. |
--host <address> | server.host, else 127.0.0.1 | Bind address. Anything but loopback requires requestAuthorization. |
--port <number> | server.port, else 4141 | Listen port. |
On start, Foundry:
- Loads
.env, then.env.local, from the root. A variable that is already set is never overwritten, so real environment variables win, and.envwins over.env.local. - Imports
foundry.config.(ts|mts|js|mjs)and the application file (foundry.application.tsunlessapplicationFilesays otherwise). - Discovers agents, writes
.foundry/routes.d.tsand.foundry/manifest.json(so the root must be writable), starts the runtime and its execution daemon, then listens.
TypeScript runs directly through tsx, a dependency of glove-foundry; there is no build step. start is dev without file watching. SIGTERM/SIGINT close the HTTP server, then stop the runtime, its daemon and running jobs. Full CLI reference: Install & CLI.
Production checklist
| Item | API | Why |
|---|---|---|
| Durable runtime data | defineApplication({ data: new FileFoundryDataAdapter({ file }) }) or your adapter | The default is in memory; agent writes during runs are otherwise lost. |
| Durable transcripts | conversationStore / store returning a durable StoreAdapter | MemoryStore gives every run an empty history. |
| Durable memory | createSqliteMemoryAdapters in defineMemory | In-memory adapters reset. |
| Persistent files | foundryDataEnvironmentPersistence or a persistent Vfs | The VFS is discarded after every run. |
| Authorization | defineApplication({ requestAuthorization }) | Required to bind a non-loopback address; gates every route. |
| Client credentials | createFoundryClient({ authorization: { identifier, headers } }) | Headers are resolved per request, outside Foundry data. |
| TLS | A reverse proxy in front of Foundry | Foundry serves plain HTTP. |
| Readiness | GET /health → check ok | Answers 503 (with the same body) when a subsystem is down, so a status-code probe works. |
| Body limits | server.messageBodyBytes | 1 MB default for message endpoints; raise only for trusted multimodal clients. |
| Concurrency and retries | execution.maxConcurrent, maxAttempts, retryBackoffMs | Each running turn is a Node process; retries re-run whole turns. |
| Event retention and export | observability.maxEvents; a custom FoundryObservabilityAdapter | Events are kept in memory only (export them). |
| Secrets | Environment variables and your adapters | Never in instance context, installation config, workspace entries or environment values. |
| One runtime per data store | — | Replicas would each re-arm every schedule (see scaling). |
The four persistence rows are explained, with a complete working setup, in Persistence.
Request authorization
Foundry binds 127.0.0.1 by default and refuses any other host (0.0.0.0, a LAN or public address) unless the application supplies requestAuthorization: listen() fails with “Foundry refuses to bind … without application.requestAuthorization”. Loopback means localhost, 127.x.x.x and ::1.
Once configured, the adapter runs before every request: inspector pages, /api/*, /v1/*, event streams, /health and /health/detailed. It receives:
| Field | Type | Value |
|---|---|---|
method | string | HTTP method |
path | string | URL path, e.g. /api/runs |
query | string | Search string including ?, or empty |
authorization | string | undefined | The Authorization header |
cookie | string | undefined | The Cookie header |
remoteAddress | string | undefined | The TCP peer (your proxy, when behind one) |
authorize returns Effect<boolean>. false, or a failure of the effect, produces a 401 with WWW-Authenticate: <challenge> (default Bearer realm="Glove Foundry") and no error detail. Foundry does not store or log the credential. This adapter accepts a bearer token for services and HTTP Basic for people, because a Basic challenge makes browsers show a login prompt for the inspector:
import { timingSafeEqual } from "node:crypto";
import { Effect } from "effect";
import type { FoundryRequestAuthorizationAdapter } from "glove-foundry";
const token = process.env.FOUNDRY_API_TOKEN;
const password = process.env.FOUNDRY_INSPECTOR_PASSWORD;
function same(actual: string, expected: string): boolean {
const a = Buffer.from(actual);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
/** Bearer for services, Basic (browser prompt) for people using the inspector. */
export const controlAuthorization: FoundryRequestAuthorizationAdapter = {
identifier: "static-control-auth",
challenge: 'Basic realm="Foundry", charset="UTF-8"',
authorize: (request) =>
Effect.sync(() => {
// Optional: let an unauthenticated load-balancer probe read GET /health only.
if (request.method === "GET" && request.path === "/health") return true;
const header = request.authorization ?? "";
if (token && header.startsWith("Bearer ")) return same(header.slice(7), token);
if (password && header.startsWith("Basic ")) {
const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
return same(decoded, `operator:${password}`);
}
return false;
}),
};import { join } from "node:path";
import { FileFoundryDataAdapter, defineApplication } from "glove-foundry";
import { controlAuthorization } from "./lib/authorization.js";
import { FileConversationStore } from "./lib/conversation-store.js";
import { dataDir } from "./lib/data-dir.js";
export default defineApplication({
name: "Support",
data: new FileFoundryDataAdapter({ file: join(dataDir, "foundry.json") }),
conversationStore: ({ conversationId }) =>
FileConversationStore.open(join(dataDir, "transcripts"), conversationId),
// Required before Foundry will bind anything other than loopback.
requestAuthorization: controlAuthorization,
});For sessions, OIDC or a trusted identity proxy, validate cookie or a proxy header inside authorize instead (wrap async checks in Effect.tryPromise). The adapter verifies; acquiring, refreshing and revoking credentials stays in your identity system. lib/conversation-store.ts and lib/data-dir.ts are on the Persistence page.
Sending credentials
import { createFoundryClient } from "glove-foundry/client";
export const foundry = createFoundryClient({
baseUrl: process.env.FOUNDRY_URL ?? "http://127.0.0.1:4141",
authorization: {
identifier: "support-backend",
// Called on every request, so rotated tokens are picked up without a restart.
headers: () => ({ authorization: `Bearer ${process.env.FOUNDRY_API_TOKEN}` }),
},
});headers receives { url, method } and may be async. An OpenAI SDK pointed at FOUNDRY_URL/v1 already sends Authorization: Bearer <apiKey>, so pass the Foundry token as its apiKey (see OpenAI-compatible API).
TLS and reverse proxies
- Foundry speaks plain HTTP. Terminate TLS at a proxy or load balancer and keep the Foundry port on a private network.
- Event streams (
/api/events,/v1/runs/:id/events, streaming/v1/chat/completionsand/v1/responses) are server-sent events. Foundry sendsX-Accel-Buffering: noand a heartbeat comment every 15 seconds; disable response buffering and keep the proxy’s read timeout above 15 seconds. remoteAddressis the socket peer. Foundry does not parseX-Forwarded-For; if your adapter trusts a proxy header, accept it only from your proxy.- The inspector is an operator console: it can create instances and conversations, start, stop and redirect runs, and approve or deny tool calls. Expose it only to operators.
Health checks
| Endpoint | Returns |
|---|---|
GET /health | ok (all of the next three are true); execution, environment, activations (each true when the execution daemon answers its ping; today all three probe the same daemon); counts agents, capabilities, surfaces |
GET /health/detailed | Everything above plus connections (application connection states) and runs (every retained run, including inputs). Keep it authenticated. |
A healthy check returns 200 with "ok": true. When a subsystem is down it returns 503 with the same body and "ok": false, so a load balancer can gate on the status code alone. Both endpoints pass through requestAuthorization, so a probe must send credentials unless your adapter exempts GET /health as above. The typed client exposes the same check as await foundry.health().
The managed execution daemon
glove foundry start
└─ Foundry control process HTTP API, inspector, instances, approvals, schedules, events
└─ execution daemon Station 3 on 127.0.0.1: run queue, recurring schedules
├─ job process one per run: imports foundry.application.ts + the agent, runs one turn
└─ job process- The runtime forks the daemon at start. The daemon binds loopback only and generates fresh random credentials on every start; keys and backend configuration never enter agent manifests.
- Each run is a separate job process. Agent code, tools and adapters execute there, not in the control process.
- Stopping Foundry gracefully stops the daemon and its jobs. If the daemon loses its connection to the control process, it shuts down too.
- The run queue and recurring schedules live in memory inside the daemon. Persisted activations are re-armed at startup; interrupted jobs are not restored.
Not supported yet: attaching to an independently administered or remote daemon, jobs that keep running after the control process exits, and distributed failover or high availability. Approvals and core callbacks pass through private local temporary files.
stationDaemon from glove-foundry/station configures the daemon Foundry already starts. Omit it for the jobs-only default.
| Option | Meaning |
|---|---|
stationId | Required. 1–128 characters of [a-zA-Z0-9_-]. |
name | Display name; defaults to stationId. |
port | Fixed loopback port (1–65535). Omit to use any free port; must be unique per runtime. |
resources | Factory run once inside the daemon, returning optional browser / sandbox providers. See Browser & sandbox. |
onReady | Called inside the daemon with { url, stationId, token }. The token has Station admin scope. |
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { defineApplication } from "glove-foundry";
import { stationDaemon } from "glove-foundry/station";
import { dataDir } from "./lib/data-dir.js";
export default defineApplication({
name: "Support",
daemon: stationDaemon({
stationId: "support",
// port: 4244, // optional fixed loopback port; default: any free port
// resources: async () => ({ ... }), // optional browser/sandbox providers
onReady: async (connection) => {
// Runs inside the daemon. The token has Station admin scope: never expose it.
await writeFile(join(dataDir, "station.json"), JSON.stringify(connection), { mode: 0o600 });
},
}),
});Execution and server config
import { defineConfig } from "glove-foundry/config";
export default defineConfig({
server: { host: "0.0.0.0", port: 4141, messageBodyBytes: 8 * 1024 * 1024 },
execution: {
maxConcurrent: 8, // agent jobs running at once (default 5)
maxAttempts: 1, // 1 = no automatic retry (default)
retryBackoffMs: 1_000,
pollIntervalMs: 100,
},
observability: { maxEvents: 10_000 },
});| Key | Default | Meaning |
|---|---|---|
server.host / server.port | 127.0.0.1 / 4141 | Overridden by --host / --port. |
server.messageBodyBytes | 1,048,576 | JSON limit for message-bearing endpoints; an integer from 1,048,576 to 134,217,728. Other endpoints stay at 1 MB. |
execution.maxConcurrent | 5 | Runs executing at the same time. |
execution.maxAttempts | 1 | Attempts per run before it fails. Retries re-run the whole turn, so raise it only when tools are idempotent. |
execution.retryBackoffMs | 1000 | Delay before a retry. |
execution.pollIntervalMs | 100 | Queue polling interval. |
execution.idlePollIntervalMs | 1000 | Deprecated; accepted but not forwarded (the daemon owns idle backoff). |
observability.maxEvents | 10000 | Size of the in-memory event buffer. |
defineConfig rejects unknown keys at compile time. agentsDir, applicationFile, strictFileRoutes and branding are covered in Project structure and Inspector & WebChat.
Scaling by operational role
- Scale the runtime up, not out. Every executing turn is a Node process; size the machine and
execution.maxConcurrenttogether. - One runtime per data store. Each runtime has its own in-memory queue and re-arms every persisted activation it finds at startup, so two runtimes over one data file or database would both fire the same schedules. There is no shared queue or failover yet.
- Scale your web tier freely. A Next.js app or API server only calls Foundry over HTTP with
FOUNDRY_URL(see Next.js integration). - Split unrelated agent systems into separate Foundry projects, each with its own runtime and data.
- Move heavy resources out. Browser and sandbox providers can run on the same Station (
resources) or as external workers that mounts connect to. - Retries from webhooks are safe. Inbound deliveries are claimed by
<routeId>:<eventId>; a repeated event returns the runs it already started.
Container recipe
# Any Node 22.13+ image (glove-memory/sqlite needs 22.13; Foundry needs 22).
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
# start writes .foundry/ under the project root; state lives on the volume.
RUN mkdir -p /data /app/.foundry && chown -R node:node /data /app/.foundry
USER node
ENV FOUNDRY_DATA_DIR=/data
VOLUME ["/data"]
EXPOSE 4141
CMD ["./node_modules/.bin/glove", "foundry", "start", "--host", "0.0.0.0", "--port", "4141"]docker build -t support-foundry .
docker run --init -p 4141:4141 \
-v support-data:/data \
-e FOUNDRY_API_TOKEN -e FOUNDRY_INSPECTOR_PASSWORD -e OPENROUTER_API_KEY \
support-foundry--host 0.0.0.0only starts because the application setsrequestAuthorization.- Point every file-based adapter (
FileFoundryDataAdapter, transcripts,memory.sqlite) atFOUNDRY_DATA_DIRon a local volume; SQLite memory does not support network filesystems. --initforwards signals and reaps child processes; Foundry starts a daemon and one process per run. Leave/tmpwritable for the per-runtime approval files.- Exclude
node_modules,.dataand.env*in.dockerignore; pass secrets as environment variables. - With pnpm, replace the install step with
corepack enable && pnpm install --frozen-lockfile --prodand copypnpm-lock.yaml.