alepha@docs:~/docs/guides/core$
cat 9-inspector.md | pretty
4 min read
Last commit:

#Inspector

Every app alepha dev runs can be inspected from outside: its actions, jobs, entities, atoms, environment and logs, read and edited by a separate tool. The devtools app (npx @alepha/devtools) is one such tool. alepha/inspector is the protocol underneath it, and nothing stops you from writing another: a script, a TUI, an MCP server for an agent.

⚠️ No stability promise before alepha 1.0. The protocol carries a version number and a client refuses a run that speaks another one, but routes and fields may change in any release until then.

#How an app becomes inspectable

No app imports the inspector. alepha dev injects it into the app it serves, and it registers in development only: never under test, and in production only in a build made for it (see Production builds).

Once the app is ready, it announces itself in the run registry:

text
~/.alepha/run/
  k3x9q2mf.json   the entry: name, cwd, git root, pid, mode, versions, log file
  k3x9q2mf.sock   the socket the inspector serves on, mode 0600

Each process gets a random run id, so two apps (or two containers, both pid 1) never collide. The entry goes away when the app stops. ALEPHA_RUN_DIR moves the directory, for tests, sandboxes or a Docker bind mount.

The inspector speaks HTTP over that Unix socket, never on the app's own port: nothing is reachable from the network, and only the socket's owner can connect. You can try it with curl:

bash
curl --unix-socket ~/.alepha/run/k3x9q2mf.sock http://localhost/metadata

#Production builds

A production bundle carries no inspector unless you ask, twice:

bash
alepha build --inspect                      # bundle it in
ALEPHA_INSPECT=1 node dist/index.node.js    # and turn it on

--inspect registers the inspector before the app starts; the manifest records "inspect": true. Started without ALEPHA_INSPECT=1, such a build stays silent: nothing listens. A build made without --inspect carries none of the inspector's code, and ALEPHA_INSPECT=1 on it logs one line saying so. The entry says "mode": "production", so you can debug ./dist locally the same way as alepha dev. The workerd slice never carries it: no socket there.

#Docker on Linux

Bind-mount the run directory, and run the container as your own user:

bash
docker run \
  --user "$(id -u):$(id -g)" \
  -v ~/.alepha/run:/run/alepha \
  -e ALEPHA_RUN_DIR=/run/alepha \
  -e ALEPHA_INSPECT=1 \
  my-app

Each container's process is pid 1, which is why entries are named by a random run id; the socket is recorded by file name and resolved against the directory the reader finds it in; and a run counts as alive when its socket accepts a connection, not by its pid, which the host cannot see.

The --user matters: a container running as root writes 0600 files owned by root, which you can neither read nor connect to. Do not loosen the mode; run as yourself. Docker Desktop on macOS is out of scope: a Unix socket does not cross its bind mount.

#Finding the apps

InspectorClient is the client side. It has no side effects of its own, so a tool injects it alone, without the inspector:

typescript
 1import { Alepha } from "alepha"; 2import { InspectorClient } from "alepha/inspector"; 3  4const alepha = Alepha.create(); 5const client = alepha.inject(InspectorClient); 6  7const list = async () => { 8  for (const run of await client.discover()) { 9    console.log(run.name, run.status, run.cwd);10  }11};

discover() returns every live run, and the last dead run of each app while its log file exists. A run is live when its socket accepts a connection. A crashed app stays listed as dead so its logs can still be read; the next run of the same app replaces it.

#Calling a run

connect(run) returns a connection whose call() takes a route by name. The request and the response are typed from the inspector's own route table:

typescript
 1import { Alepha } from "alepha"; 2import { InspectorClient } from "alepha/inspector"; 3  4const client = Alepha.create().inject(InspectorClient); 5  6const inspect = async () => { 7  const [run] = await client.discover(); 8  const app = client.connect(run); 9 10  const meta = await app.call("metadata");11  console.log(meta.system.alephaVersion, meta.actions.length);12 13  await app.call("updateAtom", {14    body: { name: "app.settings", value: { theme: "dark" } },15  });16 17  const page = await app.call("dbList", {18    params: { entity: "users" },19    query: { size: "10" },20  });21  console.log(page);22};

connect() throws when the run speaks another protocol version, naming both and the devtools release that matches the run. A dead run refuses every call except its logs.

#Following the logs

tail() is an async iterator over the run's log, oldest first. It polls the same cursor the devtools UI uses, drains a burst page by page before waiting again, and returns when the run goes away. Here is a whole tool: every error of every running app, as it happens.

typescript
 1import { Alepha } from "alepha"; 2import { InspectorClient } from "alepha/inspector"; 3  4const client = Alepha.create().inject(InspectorClient); 5  6const watchErrors = async () => { 7  const runs = await client.discover(); 8  await Promise.all( 9    runs10      .filter((run) => run.status === "live")11      .map(async (run) => {12        for await (const entry of client13          .connect(run)14          .tail({ level: "error" })) {15          console.log(`[${run.name}] ${entry.message}`);16        }17      }),18  );19};

On a dead run, logs() and tail() read the file the process left behind.

#Security

The inspector reads and mutates application state: database rows, atoms, job triggers, and the environment, secrets included, in cleartext. That is why it lives on a 0600 socket in a 0700 directory rather than on a port, and why a production build carries it only with --inspect and runs it only with ALEPHA_INSPECT=1.