npm package: @replayio/app-building
Description: Library for managing agentic app-building containers
Latest version: 1.28.0

# @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

```bash
npm install @replayio/app-building
```

## Usage

```ts
import {
  loadDotEnv,
  FileContainerRegistry,
  getInfisicalConfig,
  startContainer,
  stopContainer,
  type ContainerConfig,
  type RepoOptions,
  httpGet,
  httpOptsFor,
} 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 (set flyToken + flyApp)
const remoteConfig: ContainerConfig = {
  ...config,
  flyToken: orchestrationVars.FLY_API_TOKEN,
  flyApp: orchestrationVars.FLY_APP_NAME,
};

// Start — automatically chooses local or remote based on config
const repo: RepoOptions = { repoUrl: "https://...", cloneBranch: "main", pushBranch: "feature/x" };
const state = await startContainer(config, repo);

// Check status
const status = await httpGet(`${state.baseUrl}/status`, httpOptsFor(state));

// Query the registry
const alive = await config.registry.findAlive();

// Stop — handles both local and remote
await stopContainer(config, state);
```

## Secrets architecture

Secrets are never passed directly to the container or agent. Instead:

1. The orchestration host passes **Infisical credentials** (`InfisicalConfig`) to the container via `ContainerConfig.infisical`.
2. At startup, the container fetches global secrets from Infisical for internal use (clone token, agent API key).
3. 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.
4. The agent process runs with a **restricted environment** — only `ANTHROPIC_API_KEY` (required for the Claude CLI) is present.
5. When the agent needs to run a command that requires secrets, it uses `exec-secrets`:

```bash
exec-secrets NEON_API_KEY -- curl -s -H "Authorization: Bearer $NEON_API_KEY" https://...
exec-secrets NETLIFY_AUTH_TOKEN NETLIFY_ACCOUNT_SLUG -- netlify deploy --prod
```

The secrets server spawns the command with the requested secrets in its environment and **redacts requested secret values** from the output.

The agent can also run `list-secrets` to see which secrets are available, and `set-branch-secret` to store new branch-level secrets (e.g., `DATABASE_URL` created at deploy time). The server rejects credential values that have already appeared in logs.

## Exported API

### Domain objects

| Export | Description |
|---|---|
| `ContainerConfig` | `infisical` (required `InfisicalConfig`), optional `projectRoot` (local Docker only), `registry`, `flyToken`/`flyApp` (set both for remote Fly.io), `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"`). |
| `RepoOptions` | Per-invocation git settings: `repoUrl`, `cloneBranch`, `pushBranch`. |
| `AgentState` | Returned by `startContainer`. Contains `type`, `containerName`, `port`, `baseUrl`, and Fly-specific fields for remote containers. |
| `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. Uses local Docker if `flyToken`/`flyApp` are not set, Fly.io if they are. Returns `AgentState`. |
| `stopContainer(config, state)` | Stop a container by its `AgentState` or `RegistryEntry`. Handles both local and remote. |
| `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 (adds `fly-force-instance-id` header for remote 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`, etc. |

## Container HTTP API

Each container runs an HTTP server that accepts the following requests:

| Method | Path | Description |
|---|---|---|
| `POST /message` | `{ prompt: string }` | Add a prompt as a task in the persistent task queue. 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. |
| `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`): Set `detached` on `ContainerConfig` to start
  the container in detached mode. Use `config.initialPrompt` to 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 subsequent `POST /message` or `POST /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 `/detach` automatically
  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

Each task in the queue (local or webhook) has the following fields:

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | Auto-assigned | UUID for coordination. Auto-assigned by `add-task` if not provided. Included in `task.started`/`task.done` webhook events. |
| `skill` | `string` | Yes | Path to the skill file (e.g. `skills/tasks/build/writeApp.md`). |
| `subtasks` | `string[]` | Yes | List of subtask descriptions. |
| `app` | `string` | No | App name (directory under `apps/`). |
| `prompt` | `string` | No | Raw prompt for message-derived tasks (no skill file). |
| `command` | `string` | No | Custom command to run instead of the default `claude` CLI. |
| `maxAttempts` | `number` | No | Maximum attempts before giving up. Default: 5. |
| `timeoutMinutes` | `number` | No | Per-attempt time limit in minutes. Agent is killed if exceeded. |
| `setup` | `string` | No | Shell command to run once when the task starts (before the first agent attempt). Task fails if it exits non-zero. |
| `requiredTaskIds` | `string[]` | No | 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` | No | 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. |

### 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:

```bash
# 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"] }
]
EOF
```

### External 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:

```json
{
  "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`) + `iteration` |
| `task.done` | Task processing complete | All `Task` fields + `cost`, `totalCost`, `failed`, `pendingTasks`, `duration_ms` |
| `log` | Each log line | `line` |

### Example

```ts
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",
};
```