npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@replayio/app-building

v1.34.0

Published

Library for managing agentic app-building containers

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-building

Usage

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

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-secrets returns { secrets, allowlist }secrets are the names available before --; allowlist is name — helpString per 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's shellCommand. $0 is "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"), 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, 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. | | 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): 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:

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

{
  "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

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