Deployment
Seepient Agent Server is a stateless Node.js process that can be deployed as a Docker container, on Cloud Run, or directly on any Node.js host.
Docker
Build and run
Build the Docker container image locally from the repository root:
git clone https://github.com/seepient/seepient.git
cd seepient
docker build -t seepient .Run the server with an API key and persistent storage volume for the non-root appuser:
docker run -d -p 7337:7337 \
-v seepient-data:/home/appuser/.seepient \
seepientWith custom session directory
docker run -d -p 7337:7337 \
-e SEEPIENT_SESSION_DIR=/data/sessions \
-v session-data:/data/sessions \
-v seepient-data:/home/appuser/.seepient \
seepientDocker Compose
services:
seepient:
image: seepient
build: .
ports:
- "7337:7337"
environment:
- SEEPIENT_SESSION_TTL=86400
volumes:
- seepient-data:/home/appuser/.seepient
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7337/v1/health"]
interval: 30s
timeout: 5s
retries: 3
volumes:
seepient-data:Google Cloud Run
gcloud run deploy seepient \
--image seepient-server \
--port 7337 \
--min-instances 1 \
--max-instances 10 \
--timeout 3600 \
--set-env-vars "SEEPIENT_SESSION_TTL=86400"Cloud Run WebSocket considerations
Cloud Run has WebSocket limitations
- Heartbeat: Send a
pingmessage every 30 seconds to keep the connection alive. Cloud Run may close idle connections. - Request timeout: Cloud Run has a maximum request duration of 60 minutes. Long-running conversations should use the reconnection protocol.
- Session externalization: File-based sessions do not persist across Cloud Run instances. Use the
SEEPIENT_SESSION_DIRenvironment variable to point to a mounted volume, or externalize session storage with Redis.
Cloud Run with secrets
gcloud run deploy seepient \
--image seepient-server \
--port 7337 \
--min-instances 1 \
--max-instances 10 \
--timeout 3600 \
--set-env-vars "SEEPIENT_SESSION_TTL=86400"Bare metal / Node.js
Direct Node.js
# Install
npm install -g seepient
# Run server
seepient serverProgrammatic
import { runSeepientServer } from "seepient/server";
const server = await runSeepientServer({
port: 7337,
host: "0.0.0.0",
cors: true,
sessionTTL: 86400,
});Embedding the server in another process
runSeepientServer() is designed to be embedded inside a host application (an existing Express/Fastify app's process, a worker, a control plane).
Return value. It returns the Node.js http.Server (typed as SeepientHttpServer, i.e. http.Server & { dispose(): void }). You may attach your own listeners to it, call server.address(), close it, etc.
listen: false. Pass listen: false to receive the configured http.Server without it listening — for example to mount the server on your own host/port, or behind a router you control:
import { runSeepientServer } from "seepient/server";
const server = await runSeepientServer({ listen: false });
await new Promise<void>((resolve) => server.listen(8080, "127.0.0.1", resolve));Signal handlers. When the server listens, runSeepientServer registers SIGINT/SIGTERM handlers that close the server and force-exit after a 5-second drain. When listen: false is used, no signal handlers are registered — the embedder owns process lifecycle entirely. Repeated construction never accumulates listeners.
Dispose. The returned server carries a dispose() handle for full teardown: it un-registers the signal handlers runSeepientServer registered for that instance AND closes the server (which also detaches its WebSocket layer). Note that server.close() alone is already enough — the close event removes the signal handlers too:
server.dispose(); // un-registers handlers + closes the server
// or simply:
server.close(); // close event detaches the signal handlers as wellMultiple servers per process. runSeepientServer can be called more than once in the same process (for example, to serve from an Express or Fastify app, a CLI, or a test harness). Each call creates its own isolated HTTP server and WebSocket server instances.
However, ambient operator state (~/.seepient/setting.json and ~/.seepient) is process-wide by default — when embedding multiple servers, inject per-server persist, auditStore, policyStore, and capabilityLedger contracts to keep their state separated (see Stateless workers).
Process manager (PM2)
npm install -g pm2 seepient
# Start with PM2
pm2 start "seepient server" --name seepient
# Save for auto-restart
pm2 save
pm2 startupServer environment variables
| Variable | Description | Required |
|---|---|---|
SEEPIENT_API_KEYS_FILE | Path to server API keys JSON file | No |
SEEPIENT_SESSION_DIR | Directory for file-backed sessions | No |
SEEPIENT_SESSION_TTL | Session TTL in seconds (default: 86400) | No |
SEEPIENT_SKILLS_PATH | Colon-separated paths to skill directories | No |
SEEPIENT_MAX_BODY_BYTES | Request body size cap in bytes across all REST routes — chat, settings, gateway, and provider management (default: 10485760). Set to 0 for an unlimited body size | No |
SEEPIENT_CORS_ORIGINS | Comma-separated CORS origin allowlist, or * to reflect any origin (default: no CORS headers at all) | No |
SEEPIENT_WS_MAX_CONNECTIONS_PER_KEY | Per-key WebSocket connection cap (default: 50); dead peers are terminated by a 30s heartbeat sweep and release their slot | No |
SEEPIENT_RATE_LIMIT_RPM | Per-key requests-per-minute cap for REST and WebSocket traffic (default: 300). Set to 0 to disable | No |
Server provider isolation
The standalone server binary boots with an isolated empty provider runtime by default, and never reads provider keys from the host environment (022-5 demolition). There are exactly two ways to give the server durable providers (on a multi-tenant server, provider-management mutations and model refresh are limited to each configured account's own host — the operator baseline is derived from the providers file at boot and printed to stderr at startup):
--providers-file <path>(orrunSeepientServer({ providersFile })) — an operator-owned JSON file read once at boot into the isolated runtime. It carries provider accounts, model assignments, and credentials (the file is the operator's plaintext secret surface — mount it read-only and keep it out of tenant-visible volumes;chmod 600):json{ "providers": { "openai-main": { "adapter": "pi-ai", "upstreamProvider": "openai", "credential": { "kind": "seepient", "id": "openai-main-key" } } }, "modelAssignments": { "text": { "standard": { "providerAccount": "openai-main", "model": "gpt-5-nano" } } }, "credentials": { "openai-main-key": { "kind": "api_key", "keyValue": "sk-..." } } }The file is never written back to; runtime mutations via the provider management API stay in-memory and are lost on restart.
Inject a runtime when embedding:
runSeepientServer({ runtime })with an isolatedProviderRuntimeyou constructed yourself, stampedtenancyMode: "multi"(e.g.createIsolatedProviderRuntime({ tenancyMode: "multi", ... })) — an unstamped runtime is refused at boot withTENANCY_RUNTIME_REQUIREDrather than silently composing without egress enforcement.
Note: .seepient/setting.json in a mounted volume is not read for provider configuration — the isolated runtime never reads ambient settings files.
Egress gating for baseUrl accounts (multi-tenant arming). The server composes its runtime in multi-tenant mode, and every provider account that carries a baseUrl must have that host covered by an operator-baseline network-destination capability. Without the grant:
POST /v1/providers/:id/refresh-modelsdenies withEGRESS_REQUIREDbefore any network traffic (not even DNS resolves).PUT /v1/providers/:idwith abaseUrldenies with400 EGRESS_REQUIRED— an ungranted host cannot be planted, even by aprovider:adminkey.
Accounts without a baseUrl (default provider endpoints) are unaffected. The standalone CLI has no flag for baselines; embedders grant them at boot:
const server = await runSeepientServer({
host: "127.0.0.1",
port: 7337,
providersFile: "/config/providers.json",
operatorBaseline: [
// Grant the operator's own relay; port is optional and narrows the grant.
{ kind: "network-destination", scheme: "https", host: "relay.example.com" },
],
});Error codes
REST error codes
| Code | HTTP Status | Retryable | Description |
|---|---|---|---|
UNAUTHORIZED | 401 | No | Invalid or missing API key |
FORBIDDEN | 403 | No | API key lacks required scope |
BAD_REQUEST | 400 | No | Invalid request body or missing fields |
EGRESS_REQUIRED | 400 | No | Provider account baseUrl host is not covered by an operator-baseline network-destination capability (multi-tenant egress gating) |
NOT_FOUND | 404 | No | Endpoint or session not found |
PROVIDER_ERROR | 502 | Yes | LLM provider returned an error |
GENERATION_ERROR | 500 | Yes | Text generation failed |
INTERNAL_ERROR | 500 | No | Unexpected server error |
WebSocket error codes
| Code | Retryable | Description |
|---|---|---|
UNAUTHORIZED | No | Authentication failed on upgrade |
INVALID_MESSAGE | No | Malformed JSON |
UNKNOWN_MESSAGE_TYPE | No | Unrecognized message type |
PROVIDER_ERROR | Yes | LLM provider error |
STREAM_ERROR | No | Internal streaming failure |
SESSION_NOT_FOUND | No | Session expired or missing |
ABORTED | No | Request cancelled by client |
Retry strategy
For retryable errors (PROVIDER_ERROR, GENERATION_ERROR):
Attempt 1 ──► wait 1s ──► Attempt 2 ──► wait 2s ──► Attempt 3 ──► fail- Maximum 3 retries
- Exponential backoff: 1s, 2s, 4s
- Do not retry non-retryable errors
Graceful shutdown
When runSeepientServer() starts a listening server it handles SIGINT and SIGTERM with a 5-second drain timeout (listen: false servers register no signal handlers — the embedder owns shutdown):
- Stops accepting new connections
- Closes all active WebSocket connections with code
1001 - Stops the session cleanup timer
- Waits up to 5 seconds for in-flight requests to complete
- Force exits if drain exceeds the timeout
Health monitoring
Use the /v1/health endpoint for load balancer health checks:
curl -f http://localhost:7337/v1/health || exit 1Response:
{
"status": "ok",
"version": "0.8.0",
"uptime": 3600
}Production checklist
- [ ] Configure at least one provider account via
--providers-file(the server never reads provider API keys from the host environment; configured accounts' hosts form the derived egress baseline) - [ ] Generate API keys with minimal required scopes
- [ ] Verify
~/.seepient/server-keys.jsonpermissions are0600 - [ ] Mount a persistent volume for
./.seepient/sessions/(orSEEPIENT_SESSION_DIR) if using sessions - [ ] Configure health check against
/v1/health - [ ] Set
SEEPIENT_SESSION_TTLappropriate for your use case - [ ] Enable WebSocket heartbeat (ping/pong every 30s) for Cloud Run deployments
- [ ] Configure reverse proxy (nginx, Cloud Load Balancer) with WebSocket upgrade support
- [ ] Set up log aggregation for
[server]and[ws]log prefixes
Serverless chat routes
Deploying per-turn chat functions (no tools, no sandbox, sessions owned by your app)? Install seepient-core instead of the full package — the same chat/agent engine at a ~102 MB install closure, with the same injectable store contracts and tenancy behavior. See Stateless workers → Slim install for serverless. The full seepient server binary documented above is unchanged.