Operate / Deploy & security

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

terminalbash
# from the project root (the directory holding foundry.config.ts)
glove foundry start --host 0.0.0.0 --port 4141
FlagDefaultMeaning
--root <dir>current directoryProject root.
--host <address>server.host, else 127.0.0.1Bind address. Anything but loopback requires requestAuthorization.
--port <number>server.port, else 4141Listen port.

On start, Foundry:

  1. Loads .env, then .env.local, from the root. A variable that is already set is never overwritten, so real environment variables win, and .env wins over .env.local.
  2. Imports foundry.config.(ts|mts|js|mjs) and the application file (foundry.application.ts unless applicationFile says otherwise).
  3. Discovers agents, writes .foundry/routes.d.ts and .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

ItemAPIWhy
Durable runtime datadefineApplication({ data: new FileFoundryDataAdapter({ file }) }) or your adapterThe default is in memory; agent writes during runs are otherwise lost.
Durable transcriptsconversationStore / store returning a durable StoreAdapterMemoryStore gives every run an empty history.
Durable memorycreateSqliteMemoryAdapters in defineMemoryIn-memory adapters reset.
Persistent filesfoundryDataEnvironmentPersistence or a persistent VfsThe VFS is discarded after every run.
AuthorizationdefineApplication({ requestAuthorization })Required to bind a non-loopback address; gates every route.
Client credentialscreateFoundryClient({ authorization: { identifier, headers } })Headers are resolved per request, outside Foundry data.
TLSA reverse proxy in front of FoundryFoundry serves plain HTTP.
ReadinessGET /health → check okAnswers 503 (with the same body) when a subsystem is down, so a status-code probe works.
Body limitsserver.messageBodyBytes1 MB default for message endpoints; raise only for trusted multimodal clients.
Concurrency and retriesexecution.maxConcurrent, maxAttempts, retryBackoffMsEach running turn is a Node process; retries re-run whole turns.
Event retention and exportobservability.maxEvents; a custom FoundryObservabilityAdapterEvents are kept in memory only (export them).
SecretsEnvironment variables and your adaptersNever 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:

FieldTypeValue
methodstringHTTP method
pathstringURL path, e.g. /api/runs
querystringSearch string including ?, or empty
authorizationstring | undefinedThe Authorization header
cookiestring | undefinedThe Cookie header
remoteAddressstring | undefinedThe 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:

lib/authorization.tstypescript
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;
    }),
};
foundry.application.tstypescript
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

lib/foundry.tstypescript
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/completions and /v1/responses) are server-sent events. Foundry sends X-Accel-Buffering: no and a heartbeat comment every 15 seconds; disable response buffering and keep the proxy’s read timeout above 15 seconds.
  • remoteAddress is the socket peer. Foundry does not parse X-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

EndpointReturns
GET /healthok (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/detailedEverything 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

process treetext
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.

OptionMeaning
stationIdRequired. 1–128 characters of [a-zA-Z0-9_-].
nameDisplay name; defaults to stationId.
portFixed loopback port (1–65535). Omit to use any free port; must be unique per runtime.
resourcesFactory run once inside the daemon, returning optional browser / sandbox providers. See Browser & sandbox.
onReadyCalled inside the daemon with { url, stationId, token }. The token has Station admin scope.
foundry.application.tstypescript
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

foundry.config.tstypescript
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 },
});
KeyDefaultMeaning
server.host / server.port127.0.0.1 / 4141Overridden by --host / --port.
server.messageBodyBytes1,048,576JSON limit for message-bearing endpoints; an integer from 1,048,576 to 134,217,728. Other endpoints stay at 1 MB.
execution.maxConcurrent5Runs executing at the same time.
execution.maxAttempts1Attempts per run before it fails. Retries re-run the whole turn, so raise it only when tools are idempotent.
execution.retryBackoffMs1000Delay before a retry.
execution.pollIntervalMs100Queue polling interval.
execution.idlePollIntervalMs1000Deprecated; accepted but not forwarded (the daemon owns idle backoff).
observability.maxEvents10000Size 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.maxConcurrent together.
  • 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

Dockerfiledockerfile
# 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"]
terminalbash
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.0 only starts because the application sets requestAuthorization.
  • Point every file-based adapter (FileFoundryDataAdapter, transcripts, memory.sqlite) at FOUNDRY_DATA_DIR on a local volume; SQLite memory does not support network filesystems.
  • --init forwards signals and reaps child processes; Foundry starts a daemon and one process per run. Leave /tmp writable for the per-runtime approval files.
  • Exclude node_modules, .data and .env* in .dockerignore; pass secrets as environment variables.
  • With pnpm, replace the install step with corepack enable && pnpm install --frozen-lockfile --prod and copy pnpm-lock.yaml.