@replayio/app-building
v1.47.0
Published
Library for managing agentic app-building containers
Keywords
Readme
@replayio/app-building
Library for managing agentic app-building containers. Start and stop containers locally (Docker) or remotely (Fly.io), communicate with the in-container HTTP server, and track container state via a local registry.
Install
npm install @replayio/app-buildingUsage
import {
loadDotEnv,
FileContainerRegistry,
getInfisicalConfig,
startContainer,
stopContainer,
type ContainerConfig,
type RepoOptions,
httpGet,
containerHttpOptions,
} from "@replayio/app-building";
// Load orchestration vars from .env, then get Infisical credentials
const orchestrationVars = loadDotEnv("/path/to/project");
const infisicalConfig = await getInfisicalConfig(orchestrationVars);
// Local container (no flyToken/flyApp)
const config: ContainerConfig = {
projectRoot: "/path/to/project",
infisical: infisicalConfig,
registry: new FileContainerRegistry("/path/to/.container-registry.jsonl"),
};
// Remote container on Fly.io (set flyToken + flyApp)
const flyConfig: ContainerConfig = {
...config,
flyToken: orchestrationVars.FLY_API_TOKEN,
flyApp: orchestrationVars.FLY_APP_NAME,
};
// Remote container on Kubernetes (set k8s.gatewayUrl + k8s.authToken)
const k8sConfig: ContainerConfig = {
...config,
k8s: {
gatewayUrl: orchestrationVars.CONTAINER_GATEWAY_URL, // e.g. https://app-building.replay.io
authToken: orchestrationVars.CONTAINER_GATEWAY_TOKEN,
},
// optional: k8sResources defaults from flyGuest, else 4 CPU / 8 GiB / 50 GiB ephemeral
};
// Start — chooses backend from config: k8s if `k8s.gatewayUrl` set, else Fly if
// flyToken+flyApp set, else local Docker.
const repo: RepoOptions = { repoUrl: "https://...", cloneBranch: "main", pushBranch: "feature/x" };
const state = await startContainer(config, repo);
// Talk to the container regardless of backend. For Fly/local, httpOptsFor(state) is enough;
// for k8s use containerHttpOptions(config, state) so the gateway auth token is included.
const status = await httpGet(`${state.baseUrl}/status`, containerHttpOptions(config, state));
// Query the registry
const alive = await config.registry.findAlive();
// Stop — handles local, Fly, and k8s
await stopContainer(config, state);Backends
startContainer/stopContainer pick a backend from config, so callers use one API:
| Backend | Selected when | Per-container addressing |
|---|---|---|
| local (Docker) | neither k8s nor Fly set | http://127.0.0.1:<hostPort> |
| Fly.io | flyToken + flyApp set | https://<flyApp>.fly.dev + fly-force-instance-id: <machineId> |
| Kubernetes | k8s.gatewayUrl set (takes precedence over Fly) | <gatewayUrl> + x-container-name: <name> + x-gateway-token: <authToken> |
The k8s backend talks to an in-cluster gateway / control plane (createGatewayContainer /
destroyGatewayContainer / listGatewayContainers): the orchestrator (e.g. a Netlify
function) needs only the gateway URL + shared secret, not AWS/k8s credentials. The gateway
creates Pods via an in-cluster service account and reverse-proxies container traffic by the
x-container-name header. This mirrors Fly's two primitives (create API + instance-routing
header) so the rest of the system is backend-agnostic.
Secrets architecture
Secrets are never passed directly to the container or agent. Instead:
The orchestration host passes Infisical credentials (
InfisicalConfig) to the container viaContainerConfig.infisical.At startup, the container fetches global secrets from Infisical for internal use (clone token, agent API key).
A secrets server (
127.0.0.1:9119) runs inside the container, accessible only locally. It fetches secrets live from Infisical on every request — no caching.The agent process runs with a restricted environment — only
ANTHROPIC_API_KEY(required for the Claude CLI) is present.Any time a secret is needed, the caller invokes
exec-secrets:exec-secrets <SECRET1> [SECRET2 …] -- <target> [args …]The secrets server spawns the target with the named secrets in its environment and redacts those secret values from the output.
Branch-scoped secrets (/branches/<branch>/) are read from the repo's push
branch by default. Set ContainerConfig.secretsBranch to read another
branch's secrets instead — e.g. point a feature branch at the secrets
provisioned for main rather than provisioning its own. This also redirects
where set-branch-secret writes, so a container reading another branch's
secrets writes back to that same branch. Global secrets are unaffected.
exec-secrets is invoked recursively. The agent's own shell has no secrets,
so when it runs an app script — npm run test, npm run deploy, a seed
script, a migration — that script calls exec-secrets itself for each
operation that needs a secret. One agent task can produce dozens of
exec-secrets invocations from inside scripts it never directly typed.
Three commands you'll see
| Command | Purpose |
|---|---|
| exec-secrets <SECRETS…> -- <target> [args…] | Run <target> with the named secrets injected, output redacted. |
| list-secrets | Print the secret names the container can resolve. With an allowlist configured, also prints the allowed targets. |
| set-branch-secret <NAME> <value> | Store a new branch-scoped secret in Infisical (e.g. DATABASE_URL after provisioning Neon). Rejected if <value> has already appeared in logs. |
In unrestricted mode (no allowlist), <target> is any binary the
container has installed — curl, psql, npx netlify, etc. In
restricted mode (allowlist configured), <target> must be one of the
allowlist entry names; see Allowlist mode below.
Allowlist mode
Set ContainerConfig.secretAllowlist to restrict the set of secret-using
operations available in the container. With an allowlist configured, every
exec-secrets call — whether issued directly by the agent or by an app
script the agent runs (npm run test, deploy scripts, seed scripts,
migrations) — must name an entry; calls naming an arbitrary binary are
rejected.
Each entry is { name, helpString, shellCommand }. The shellCommand body
runs under sh -c; positional args supplied after the target become $1,
$2, … and the named secrets are present in the environment.
Design principle: one entry per verb
Each entry should encode one specific operation. The shellCommand
hardcodes URL, method, file path, and any other fixed structure; positional
args carry only the data the operation needs. The caller supplies "what to
operate on", never "what to do".
Good entries
secretAllowlist: [
{
name: "neon-create-branch",
helpString: "Create a Neon branch. Args: <project_id> <branch_name>",
shellCommand:
'curl -fsS -X POST "https://console.neon.tech/api/v2/projects/$1/branches" ' +
'-H "Authorization: Bearer $NEON_API_KEY" ' +
'-H "Content-Type: application/json" ' +
'-d "{\\"branch\\":{\\"name\\":\\"$2\\"}}"',
},
{
name: "neon-delete-branch",
helpString: "Delete a Neon branch. Args: <project_id> <branch_id>",
shellCommand:
'curl -fsS -X DELETE "https://console.neon.tech/api/v2/projects/$1/branches/$2" ' +
'-H "Authorization: Bearer $NEON_API_KEY"',
},
{
name: "netlify-deploy-prod",
helpString: "Deploy the current build to production. No args.",
shellCommand:
'npx netlify deploy --prod --auth "$NETLIFY_AUTH_TOKEN" --site "$NETLIFY_SITE_ID"',
},
{
name: "replay-upload-all",
helpString: "Upload all pending Replay recordings. No args.",
shellCommand: 'npx replayio upload --all --api-key "$RECORD_REPLAY_API_KEY"',
},
]Each of these:
- Pins the URL, method, and headers — the caller can't redirect a Neon API token at a different host or use it for an operation that wasn't allowed.
- Pins the binary and its flags — Netlify / Replay invocations always carry the right auth and the intended verb.
- Takes data only — branch names, project IDs, etc.
Anti-patterns
Don't pass "$@" through to a primitive tool. These look like allowlist
entries but they're not constraining anything:
// BAD — caller can curl any URL with the Neon token attached.
{ name: "curl", shellCommand: 'curl "$@"' }
// BAD — caller can run arbitrary SQL, including `\!` shell escapes.
{ name: "psql", shellCommand: 'psql "$@"' }
// BAD — caller can run arbitrary JS with every secret in env.
{ name: "node", shellCommand: 'node "$@"' }
// BAD — sh/bash with -c is identical to "run anything".
{ name: "shell", shellCommand: 'sh -c "$1"' }If the same upstream API has ten operations the app needs, write ten entries — each hardcoding URL + method + headers, each taking only the data fields the operation requires.
In-repo scripts aren't a security boundary
The allowlist only constrains operations whose semantics live outside the
repo — upstream HTTP APIs, third-party CLIs invoked with fixed flags. For
anything that runs code the agent wrote (npx tsx scripts/seed-db.ts,
node scripts/migrate.js, npm run <anything>), the agent controls the
file contents and can do whatever it likes with the secrets in env.
Pinning a path doesn't help — the agent can edit the file.
So: do not add allowlist entries that exist to run an in-repo script.
Instead, write narrow API-level entries (neon-create-branch,
netlify-env-set, …) and have the in-repo script call exec-secrets for
each of those verbs as needed. The allowlist then describes the set of
operations the system supports, and the agent's code composes them.
Behavior when configured
list-secretsreturns{ secrets, allowlist }—secretsare the names available before--;allowlistisname — helpStringper entry, available as targets after--.exec-secrets <SECRET1> [SECRET2 …] -- <name> [args…]: named secrets are injected into env (and only those values are redacted from output);<name>must match an entry;[args…]become$1,$2, … inside the entry'sshellCommand.$0is"exec-secrets".- Targets not in the allowlist are rejected with
Unknown allowlist entry.
secretAllowlist is serialized into the container as SECRET_ALLOWLIST_JSON;
the secrets server parses it on startup. To swap or clear the allowlist on
a running container without restarting, POST to /reconfigure (see
Container HTTP API).
Exported API
Domain objects
| Export | Description |
|---|---|
| ContainerConfig | infisical (required InfisicalConfig), optional projectRoot (local Docker only), registry, flyToken/flyApp (set both for remote Fly.io), flyGuest (override Fly Machine guest sizing; default: 16 performance CPUs / 32 GiB), flyVolumeSizeGb (override Fly Volume size in GiB; default: 50), imageRef, webhookUrl/webhookSecret, taskWebhookUrl (GET endpoint for external task queue), addTaskWebhookUrl (POST endpoint for tasks added by add-task script), detached, initialPrompt, localPort, absorbTasks, namePrefix (default: "app-building"), secretsBranch (branch to read/write branch-scoped secrets from in Infisical; defaults to the push branch — see Secrets architecture), model (override the agent model for the initial prompt and queued prompt tasks; swaps the default claude-opus-4-8 while keeping MCP servers and permissions), env (extra env vars to inject into the container; cannot clobber package-reserved vars), secretAllowlist (curated exec-secrets commands — see Secrets architecture > Allowlist mode). |
| FlyGuest | Fly Machine guest spec: cpu_kind ("shared" | "performance"), cpus, memory_mb. |
| SecretAllowlistEntry | name (verb used with exec-secrets), helpString (one-line description shown by list-secrets), shellCommand (sh script body; args become $1, $2, …). |
| RepoOptions | Per-invocation git settings: repoUrl, cloneBranch (single branch, or a comma-separated priority list like "feature-x,main" where the first branch that exists on the remote is cloned), pushBranch. |
| AgentState | Returned by startContainer. Contains type ("local" | "remote" | "k8s"), containerName, port, baseUrl, and Fly-specific fields for remote containers. |
| GatewayConfig | k8s backend config: gatewayUrl, authToken. Set as ContainerConfig.k8s. |
| K8sResources | Pod sizing for the k8s backend: cpu (cores), memoryMb, optional storageGb (default 50). Set as ContainerConfig.k8sResources; defaults derived from flyGuest. |
| ContainerRegistry | Interface for container registry storage. Methods: log, markStopped, clearStopped, getRecent, find, findAlive. |
| FileContainerRegistry | Built-in file-backed implementation of ContainerRegistry, backed by a .jsonl file. |
Container lifecycle
| Export | Description |
|---|---|
| startContainer(config, repo) | Start a container. Backend chosen from config: k8s if k8s.gatewayUrl set, else Fly.io if flyToken/flyApp set, else local Docker. Returns AgentState. |
| stopContainer(config, state) | Stop a container by its AgentState or RegistryEntry. Handles local, Fly, and k8s. |
| createGatewayContainer(gw, req) / destroyGatewayContainer(gw, name) / listGatewayContainers(gw) | Low-level k8s gateway client (used internally by startContainer/stopContainer; exported for reaping/admin/listing). |
| buildImage(config) | Build the Docker image locally (called automatically by startContainer for local containers). |
| spawnTestContainer(config) | Start an interactive (-it) local container with the repo mounted at /repo. |
| loadDotEnv(projectRoot) | Parse a .env file and return key-value pairs. |
Container registry (ContainerRegistry interface / FileContainerRegistry class)
| Method | Description |
|---|---|
| log(state) | Append a new entry to the registry. |
| markStopped(name?) | Mark a container as stopped. |
| clearStopped(name) | Clear the stopped flag (container came back alive). |
| getRecent(limit?) | Read the most recent registry entries. |
| find(name) | Find a specific container by name. |
| findAlive() | Probe recent entries and return those that are alive. Reconciles stopped flags. |
Types: RegistryEntry
HTTP client
| Export | Description |
|---|---|
| httpGet(url, opts?) | GET with retries and timeout. Returns parsed JSON. |
| httpPost(url, body?, opts?) | POST with retries and timeout. Returns parsed JSON. |
Types: HttpOptions
Container utilities
| Export | Description |
|---|---|
| httpOptsFor(state) | Return HttpOptions for a container from state alone (adds fly-force-instance-id for Fly, x-container-name for k8s — no gateway token). |
| containerHttpOptions(config, state) | Return HttpOptions including auth (adds the k8s gateway token from config.k8s). Prefer this for talking to k8s containers. |
| probeAlive(entry) | Check if a container is responding to /status. |
Secrets (Infisical)
| Export | Description |
|---|---|
| infisicalLogin(clientId, clientSecret) | Log in via Universal Auth, returns a short-lived access token. |
| getInfisicalConfig(envVars) | Extract InfisicalConfig from env vars and log in. Requires INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET, INFISICAL_PROJECT_ID, INFISICAL_ENVIRONMENT. |
| fetchGlobalSecrets(config) | Fetch secrets from the /global/ path. |
| fetchBranchSecrets(config, branch) | Fetch secrets from /branches/<branch>/. |
| createBranchSecret(config, branch, name, value) | Create or update a secret in /branches/<branch>/. Creates the folder if needed. |
| fetchInfisicalSecrets(config, path) | Raw fetch from any Infisical folder path. |
Types: InfisicalConfig
Image ref
| Export | Description |
|---|---|
| getImageRef() | Returns CONTAINER_IMAGE_REF env var, or ghcr.io/replayio/app-building:latest by default. |
Task dependencies
| Export | Description |
|---|---|
| findReadyTask(pendingTasks, completedTasks) | Find the first task in pendingTasks whose requiredTaskIds are all satisfied. A required task is satisfied when it appears in completedTasks and has no children still pending or with incomplete children of their own (recursive). Returns the task, or null if all are blocked. |
| Task | Interface for task queue entries — includes skill, subtasks, timestamp, and optional fields like id, requiredTaskIds, parentTaskId, command (full agent-invocation override), model (override just the agent model — swaps the default claude-opus-4-8 while keeping MCP servers, permissions, and the rest of the default command; ignored when command is set), etc. |
Container HTTP API
Each container runs an HTTP server that accepts the following requests:
| Method | Path | Description |
|---|---|---|
| POST /message | { prompt: string, model?: string } | Add a prompt as a task in the persistent task queue. Optional model overrides the agent model for that task (keeps MCP servers/permissions; see Task.model). Returns { ok: true }. |
| POST /detach | | Signal the container to exit once all tasks are done. |
| POST /stop | | Force-stop the container immediately. Interrupts any running work, commits remaining changes, then exits. |
| POST /interrupt | | Kill the currently running Claude process without stopping the container. |
| POST /wakeup | | Wake an idle container without enqueueing a message. The worker loop will iterate and, if no local task is queued, fetch one from TASK_WEBHOOK_URL. Returns { ok: true }. |
| POST /reconfigure | { secretAllowlist?: SecretAllowlistEntry[] \| null } | Live-update container config. Omit to leave unchanged; null for unrestricted mode (exec-secrets <SECRETS…> -- <cmd> runs any binary); [] for restricted mode with zero entries (rejects every target); an array of entries to replace. Returns { ok: true }. |
| GET /status | | Container state, queue depth, iteration count, cost, revision, etc. |
| GET /events?offset=N | | Stream of Claude events (JSON lines) since offset. |
| GET /logs?offset=N | | Stream of log lines since offset. |
Container lifecycle
A container stays running and accepts messages until it receives a detach or stop signal:
- Detached at startup (
config.detached = true): SetdetachedonContainerConfigto start the container in detached mode. Useconfig.initialPromptto provide a prompt that is queued before the HTTP server starts accepting requests. The container processes the initial prompt and any queued tasks, then exits cleanly. This is the preferred way to run fire-and-forget jobs — no race between container startup and a subsequentPOST /messageorPOST /detach. - Detach (
POST /detach): Signal a running container to exit once all in-flight and queued work is done. In the CLI, interactive mode (npm run agent -- -i) sends/detachautomatically when the user disconnects (Ctrl+C/D). - Stop (
POST /stop): The container exits immediately, interrupting any running Claude process. It commits any remaining work before shutting down. This is the forced shutdown path.
Without either signal, the container waits indefinitely for new messages — this is intentional so that interactive users can send follow-up messages at any time.
Task absorption
Set config.absorbTasks = true to have the container absorb task files from other containers
at startup. This is off by default. When enabled, the container scans tasks/ for task files
belonging to other containers, merges their tasks into its own queue, and deletes the foreign files.
Task structure
Every task specifies its work in one of three ways, and must satisfy exactly one of these:
- Skill task —
skillplus a non-emptysubtasks. The agent reads the skill and works the subtasks. - Prompt task —
prompt. The agent runs the raw prompt (no skill file). - Setup-only task —
setupwith nopromptand nosubtasks. Only the setup script runs; no agent is invoked. Once it exits successfully the task is committed and completed; if it exits non-zero the task fails and is dropped.skillandsubtasksare omitted, andadd-taskaccepts the task without them. Use this to run provisioning or fixture scripts as a standalone queue step.
All other fields below are optional and apply to any of the three forms.
| Field | Type | Description |
|---|---|---|
| id | string | UUID for coordination. Auto-assigned by add-task if not provided. Included in task.started/task.done webhook events. |
| skill | string | Path to the skill file (e.g. skills/tasks/build/writeApp.md). Required for a skill task. |
| subtasks | string[] | List of subtask descriptions. Required (non-empty) for a skill task. |
| prompt | string | Raw prompt for message-derived tasks (no skill file). Defines a prompt task. |
| setup | string | Shell command to run once when the task starts, before any agent attempt. The task fails and is dropped if it exits non-zero. On its own (no prompt/subtasks) it defines a setup-only task. |
| app | string | App name (directory under apps/). |
| command | string | Custom command to run instead of the default claude CLI. |
| maxAttempts | number | Maximum attempts before giving up. Default: 5. |
| timeoutMinutes | number | Per-attempt time limit in minutes. Agent is killed if exceeded. Also caps a setup command's runtime (default 120s). |
| requiredTaskIds | string[] | Task IDs that must complete before this task can start. A task is "complete" when it and all its child tasks have finished. |
| parentTaskId | string | ID of the task that spawned this task (set automatically by add-task). Used for dependency resolution — a required task isn't considered complete until all its children are done. |
| proxy | { server: string; bypass?: string } | When set, the MCP browser is (re)launched routed through this proxy before the task runs. The container compares each task's proxy.server to the one currently in use and only relaunches the Playwright MCP server (with an isolated profile, so nothing leaks across the switch) when it changes. |
| mcpErrorWebhook | string | POST endpoint to receive Replay MCP backend errors observed while this task runs (network/HTTP/JSON-RPC errors, isError tool results, timed-out tool calls). See "Replay MCP error webhook" below. |
Task dependencies
Tasks can declare dependencies on other tasks via requiredTaskIds. The worker skips blocked
tasks and picks the first task whose dependencies are all satisfied. A dependency is satisfied
when the required task has completed and all of its child tasks (tasks with matching
parentTaskId) have also completed. This means a task that depends on a parent will
automatically wait for all work the parent spawned.
By default, add-task chains tasks serially — each task depends on the previous one. Set
"parallel": true on individual tasks to run them concurrently. Non-parallel tasks act as
barriers:
# Serial (default): B waits for A, C waits for B
npx tsx /repo/scripts/add-task.ts <<'EOF'
[{ "skill": "...", "subtasks": ["A"] }, { "skill": "...", "subtasks": ["B"] }, { "skill": "...", "subtasks": ["C"] }]
EOF
# Mixed: A runs first, B and C run in parallel, D waits for both B and C
npx tsx /repo/scripts/add-task.ts <<'EOF'
[
{ "skill": "...", "subtasks": ["A"] },
{ "skill": "...", "parallel": true, "subtasks": ["B"] },
{ "skill": "...", "parallel": true, "subtasks": ["C"] },
{ "skill": "...", "subtasks": ["D"] }
]
EOFExternal task queue
Set config.taskWebhookUrl to a GET endpoint that returns tasks to process. When the
local task queue is empty, the container GETs this URL (with the same Bearer token from
webhookSecret) and expects a JSON response:
{ "tasks": [{ "skill": "...", "subtasks": [...], ... }, ...] }— process these tasks{}or{ "tasks": [] }— no tasks available, go idle
The first task is processed immediately; any remaining tasks are added to the local queue. Tasks use the same structure documented above.
Add-task webhook
Set config.addTaskWebhookUrl to a POST endpoint that receives tasks added by the add-task
script. When set, the agent's add-task calls POST tasks to this URL instead of writing to
the local task file. The request body is { "tasks": [...] } with the same task structure
documented above. Auth uses the same Bearer token from webhookSecret.
Webhooks
Set webhookUrl on ContainerConfig to receive real-time notifications of container activity. The container POSTs JSON to that URL on key events (no retries; failures are logged to stderr). Set webhookSecret to include a Bearer token in the Authorization header for authenticating webhook requests.
Payload format
Every POST body has this shape:
{
"type": "container.started",
"containerName": "app-building-abc123",
"timestamp": "2026-02-28T12:00:00.000Z",
"data": { ... }
}| Field | Type | Description |
|---|---|---|
| type | string | Event type (see table below). |
| containerName | string | Name of the container that emitted the event. |
| timestamp | string | ISO-8601 timestamp. |
| data | object | Event-specific payload. Present on all events; contents vary by type. |
Events
| Type | When | data fields |
|---|---|---|
| container.started | HTTP server is listening | pushBranch, revision |
| container.idle | Container is waiting for work | pendingTasks, queueLength |
| container.stopping | Container is shutting down | (empty) |
| container.stopped | Container has stopped | (empty) |
| message.queued | POST /message received | messageId, prompt |
| message.started | Message processing begins | iteration, prompt |
| message.done | Message processing complete | messageId, cost_usd, duration_ms, num_turns |
| message.error | Message processing failed | messageId, error |
| task.started | Task processing begins | All Task fields (id, skill, subtasks, timestamp, app, prompt, command, maxAttempts, timeoutMinutes, setup, requiredTaskIds, parentTaskId, proxy, mcpErrorWebhook) + iteration |
| task.done | Task processing complete | All Task fields + cost, totalCost, failed, pendingTasks, duration_ms |
| log | Each log line | line |
Replay MCP error webhook
Inside the container, the agent's replay MCP server does not dial the
upstream (https://dispatch.replay.io/nut/mcp, overridable via the
REPLAY_MCP_URL env var) directly. It dials a local forwarding proxy that
relays every request and response verbatim — including streamed SSE responses —
while watching tools/call traffic for backend failures. When the upstream is
healthy the proxy is invisible to the agent; failures are logged, counted in
GET /status (replayMcpErrors), and reported to the running task's
mcpErrorWebhook when one is set.
Set mcpErrorWebhook on a task to receive a POST for each failure observed
while that task runs. Requests use the standard webhook envelope and the same
Bearer auth from webhookSecret:
{
"type": "mcp.error",
"containerName": "app-building-abc123",
"timestamp": "2026-06-12T12:00:00.000Z",
"data": {
"taskId": "…",
"tool": "initial-chat-message",
"params": { "message": "hello" },
"operatorId": "op_123",
"agentId": "agent_456",
"kind": "timeout",
"message": "no reply after 120000ms (call left running)",
"durationMs": 120001
}
}data.kind is one of:
| Kind | Meaning |
|---|---|
| network-error | The upstream fetch failed (DNS, connect, reset). |
| http-error | The upstream answered a tool call with a non-2xx status. |
| rpc-error | The JSON-RPC response carried an error object. |
| tool-error | The tool result was flagged isError: true. |
| timeout | No reply within the timeout window (default 120s, REPLAY_MCP_TOOL_TIMEOUT_MS). The call is reported but not aborted, so a slow-but-successful call still completes for the agent. |
| no-response | The upstream response ended without ever answering the call. |
data.tool is the tool name from the tools/call request, data.params is
that call's arguments, data.operatorId/data.agentId are the request's
x-replay-operator-id/x-replay-agent-id headers (omitted when absent), and
data.durationMs is the time since the call was
forwarded. Errors that occur while no task with mcpErrorWebhook is running are
still logged and counted, just not POSTed.
Example
const orchestrationVars = loadDotEnv("/path/to/project");
const infisicalConfig = await getInfisicalConfig(orchestrationVars);
const config: ContainerConfig = {
projectRoot: "/path/to/project",
infisical: infisicalConfig,
registry: new FileContainerRegistry("/path/to/.container-registry.jsonl"),
webhookUrl: "https://example.com/hooks/container-events",
webhookSecret: "your-webhook-secret",
};