Porthole

Read-only SQL over a live BEAM system, built for coding agents.

A porthole is a window you can look through but not pass through. Porthole exposes a running node's processes, supervisors, ETS tables and applications as SQL tables, so an agent can compose one precise question instead of calling many narrow tools. It is a sibling of Airlock: Airlock controls what agents can do, Porthole controls what they can see.

Status: spike. Only the observe capability tier is implemented.

Does it help? In a blind eval on an app with nine planted problems, an agent with Porthole found 9/9 in about a minute without touching the node. The same agent with only a shell found 7/9 in about two minutes, made one wrong claim, and along the way copied a whole mailbox and a whole ETS table, ran application code and enabled tracing on a live process. Two later Porthole runs found 7/9 and 8/9, with no wrong claims and nothing changed: the problems they missed were ones they never looked at. These are single runs, so indicative rather than conclusive.

Try it on your production app

Porthole is not a dependency of your app. It runs next to it, as a separate sidecar that holds the app's cookie: agents get a URL and a token, never the cookie. Your app is not changed or redeployed; any Elixir app on OTP 27+ whose nodes are clustered with long names works.

$ mix archive.install hex porthole # once; remove it with: mix archive.uninstall porthole
$ mix porthole.fly.up my-app # on Fly.io
$ mix porthole.k8s.up my-app --namespace prod # or on Kubernetes (my-app is the Deployment)

up reads your app's settings, deploys the sidecar, checks that it sees your nodes, and prints the two commands left: the tunnel, and the one that connects your agent. mix porthole.fly.down my-app (or k8s.down) removes everything. Details: Fly.io, Kubernetes.

The archive provides the commands that need no project (fly.up/down, k8s.up/down, gen.token); the ones that run queries locally (query, mcp, doctor, server) run inside a project that depends on Porthole. up and down use a Unix shell: on Windows, run them under WSL. Archives are installed per Elixir version (asdf and mise keep one Mix home per version), so install it with the Elixir version you run it with.

To install nothing at all, run the same commands through Mix.install:

$ elixir -e 'Mix.install([:porthole]); Mix.Task.run("porthole.fly.up", ["my-app"])'

Other ways to use it

# From a checkout of Porthole: try it on a small app with planted problems
$ mix porthole.query --demo \
"SELECT initial_call, sum(message_queue_len) AS queued FROM processes GROUP BY 1 ORDER BY 2 DESC LIMIT 3"
# Query a running app from a separate VM; sample over 5s for _delta columns
$ mix porthole.query --connect my_app@127.0.0.1 --cookie secret --window 5000 \
"SELECT registered_name, reductions_delta FROM processes ORDER BY 2 DESC LIMIT 10"

The app being queried does not need Porthole: it only needs to be an Elixir app on OTP 27+ that you can connect to with its cookie. If a node doesn't answer, mix porthole.doctor checks each node (reachable, Elixir, OTP, a real collection) and explains what to fix.

From a remote shell or IEx: Porthole.print("SELECT ..."), or Porthole.query/2 for a Porthole.Result.

For agents, Porthole is an MCP server with one query tool.

Example questions

-- Which ETS tables grow fastest, and who owns them? (--window 10000)
SELECT e.name, e.size_delta, p.registered_name AS owner
FROM ets_tables e JOIN processes p ON p.node = e.node AND p.pid = e.owner
ORDER BY e.size_delta DESC LIMIT 5;
-- Which application's processes use the most memory?
SELECT application, count(*), sum(memory) FROM processes GROUP BY 1 ORDER BY 3 DESC;
-- Orphans: no links, no monitors, not supervised
SELECT pid, initial_call FROM processes
WHERE links_count = 0 AND monitors_count = 0 AND monitored_by_count = 0
AND pid NOT IN (SELECT child_pid FROM supervisors WHERE child_pid IS NOT NULL);

Guides

Tables

Table One row per Columns
processes process pid, registered_name, initial_call, current_function, waiting_on, label, application, ancestors, status, message_queue_len, memory, binary_memory, reductions, links/monitors/monitored_by counts
supervisors supervisor child pid, name, module, child_id, child_pid, child_status, child_type
ets_tables ETS table id, name, owner, type, protection, size, memory
ports port / socket port, name, owner, local_address, remote_address, os_pid, input, output, queue_size, memory
applications loaded app name, vsn, description, running
system node memory by category, process/atom/port/ETS counts vs limits, run_queue, schedulers, uptime

Every table has a node column. With a window, processes gains reductions_delta, memory_delta, message_queue_len_delta and binary_memory_delta, ets_tables gains size_delta and memory_delta, ports gains input_delta, output_delta and queue_size_delta, and system gains deltas for memory, process and port counts, and reductions.

How it works

For every query, Porthole finds the tables the SQL mentions, collects them right now on each requested node, loads the rows into a fresh in-memory SQLite database, runs the query and throws the database away. SQLite is a query engine here, not a replica: it provides the joins, aggregates and subqueries, and the running system stays the source of truth.

Security

Porthole's tool is read-only, but the sidecar holds your cluster's cookie: read what it guarantees and what it does not, and report vulnerabilities privately as described there.

Development

$ mix test # includes multi-node tests using :peer

test/porthole/eval_test.exs answers each eval question with one query against the demo app (a small shop with planted problems, in test/support).