#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:
~/.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:
curl --unix-socket ~/.alepha/run/k3x9q2mf.sock http://localhost/metadata
#Production builds
A production bundle carries no inspector unless you ask, twice:
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.
ALEPHA_INSPECT reads the same everywhere: 1 or true turns the inspector on
even under test or production, 0 or false turns it off even under
alepha dev, and unset leaves it on in development only.
#Docker on Linux
Bind-mount the run directory, and run the container as your own user:
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:
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:
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.
tail() takes level (the minimum level), history (how many past entries to
replay before following, 0 by default, or the whole log file on a dead run,
which yields it once and returns), intervalMs (the poll delay once caught up,
1000 by default) and an AbortSignal as signal, which ends the iterator.
logs() answers one page instead, newest first.
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.