crystalia-tower
An ATC-style triage tower for agent and human callsigns.
Work performed by an agent session is invisible until a human relays it, which
makes the human the transport wire between concurrently-running sessions.
crystalia-tower replaces that wire: every participant -- agent or human --
is a node with a callsign, an inbox, and presence. Messages are addressed, typed
from a fixed eight-verb vocabulary, and carry refs rather than bulk.
The thing it is for: an agent queues "need your ruling", the human answers, the agent unblocks -- with no live coordinator session and no copy-paste.
Install
pip install crystalia-tower
Python 3.13 or newer. Installs four console scripts: ctower, ctower-cab,
ctower-launch and ctower-serve.
Quickstart
A tower is a directory on some host. init is the only verb that will not guess
where it is; the rest read CRYSTALIA_TOWER_HOME, or take --tower. That flag
takes the directory, or a URL to a tower being served over the network.
ctower init --tower ~/towers/demo
export CRYSTALIA_TOWER_HOME=~/towers/demo
export CRYSTALIA_TOWER_CALLSIGN=vlad
# hand work to an agent callsign that need not exist yet
ctower send ASSIGN gatto-1 unit=DEMO note='look at the failing test'
# the agent's side: what is waiting, and what it owes a readback
ctower check --callsign gatto-1
ctower ack <msg-id> --callsign gatto-1
# the tower, both axes, every strip
ctower status
ctower roll
check --wait 300 blocks until traffic lands instead of polling, and exits 0
with interrupted: true in the envelope if a signal cuts the wait short --
a killed wait found nothing out, and that is not the same as a quiet tower.
The eight verbs
ASSIGN REPORT WILCO ROGER STANDBY UNABLE SAY-AGAIN MAYDAY
The set is closed. A ninth token is a validation failure, not an extension
point. A message carries unit=, status=, reason=, ref= and note=;
note= bytes are stored byte-identically, never parsed, never interpolated
into a shell, and never handed to the crash reporter. Against a tower served
over the network they travel to that tower, and nowhere else.
The four commands
| Command | What it is |
|---|---|
ctower |
the CLI: send, inbox, check, ack, status, init, roll |
ctower-cab |
a terminal UI over your own inbox and the strip tower |
ctower-launch |
start an agent process with its queued traffic handed over as its brief |
ctower-serve |
serve one tower over HTTP so other hosts can reach it |
Machine-readable output
Every verb takes --json and emits a versioned envelope. The seven schemas that
pin it ship in the source distribution under docs/json-schema/v1/, one per
verb, identified as urn:crystalia-tower:json-schema:v1:<verb>. The
envelope is a public API: it is pinned by test, not described by prose.
Exit codes are frozen: 0 success, 2 usage, 3 validation, 4 unknown
callsign, 5 unknown msg-id, 6 permission, 7 tower unavailable, 8 launch
collision. ctower-launch adds 1 for a failure to start the child -- the
tower committed its work and what failed was the exec -- and otherwise exits
with the child's own code, reporting a signalled child as 128+N. Three
non-happy outcomes are exit 0 by design: a --wait deadline expiring, a
message to a callsign never seen before, and a launch over an empty queue.
Reaching a tower from another host
One host needs nothing. The tower is a directory and every participant reaches it through the filesystem.
Several hosts need a server. ctower-serve puts one tower behind HTTP;
clients then pass a URL where they would have passed the directory.
# on the tower's host, once
ctower init --tower ~/towers/demo # prints the tower's id
ssh-keygen -t ed25519 -f ~/keys/agent-1 -N '' # one keypair per client
cat ~/keys/agent-1.pub >> ~/towers/demo/authorized_keys
ctower-serve --tower ~/towers/demo --bind 0.0.0.0:8765 \
--authorized-keys ~/towers/demo/authorized_keys --allow 10.0.0.0/8
# on a client host: the private key goes beside the config file
mkdir -p ~/.config/crystalia-tower
install -m 600 ~/keys/agent-1 ~/.config/crystalia-tower/id_ed25519
ctower roll --callsign agent-1 --tower 'http://tower.internal:8765#t-...'
--authorized-keys is an OpenSSH authorized_keys file -- ed25519 public keys,
one per line -- and it is re-read on a four-hour timer, so adding a line or
striking one out takes effect without a restart, though not at once.
Deleting that file revokes every key on it at the next re-read, and the server says so on stderr. Any other read failure -- a permission change, a bad disk -- keeps the keys it already had and warns instead, because a host failing is not an operator revoking, and one bad read should not lock everyone out for four hours. The accepted cost of that split: a storage mount that disappears looks exactly like a deletion and will evict the fleet. Keep the file on local disk. Deletion takes effect at the next re-read, so restart the server when a revocation is urgent -- it reads the file once at startup.
--bind is only the
listening socket; --allow is the guard, and it is what refuses a caller.
Binding 0.0.0.0 behind a correct
--allow is not weaker than binding one interface.
-N '' is not a shortcut: a passphrase-protected key cannot be used here.
Nothing on the command line or in the config can supply the password, so the
client refuses it with exit 3 and says so. Protect the file with permissions --
install -m 600, as above.
The fragment on that URL is the tower's own identity, minted by init and
printed at startup. It pins which tower, not where it is. Pin it. Without
one, the client asks the address who it is and binds to whatever answers; with
one, a different tower answering at a familiar address is refused rather than
quietly accepted. The fragment is not transmitted as part of the URL, but the
pinned id is sent on every request and the signature covers it -- it is private
from the address, not from the tower. A URL may come from --tower or
CRYSTALIA_TOWER_HOME and never from a config file -- config is per-user, and a
URL there would follow you into somebody else's tower.
Every request is signed with that client key, and the fingerprint recorded
against each row is the one the server verified, never one the caller asserted.
A callsign is not authenticated -- it is not among the signed octets -- so a
fingerprint says which key holder wrote a row and never which callsign. A key
that is not admitted is refused with exit 6, naming the fingerprint it saw,
and nothing is written. A tower that does not answer at all is exit 7.
A refusal and an outage are deliberately different codes, because only one of
them is worth retrying.
Traffic is pull-only. No notifier, no cron, no hook and no watcher sits on
the message path -- you learn you have mail by asking, over a URL exactly as
over a directory. ctower-serve answers requests and never initiates one. The
single narrow exception is crash telemetry, which is on by default and switched
off with CRYSTALIA_TOWER_SENTRY_DISABLED=1. It never carries note= bytes.
Working on the source
The source distribution carries the tests and the schemas.
uv sync --extra dev
uv run pytest
uv run ruff check src tests
uv run mypy src/crystalia_tower
Always use uv run; never source .venv/bin/activate.
Two invariants are easy to break and are enforced by lint rather than by
review: sqlite3 may be imported only under
src/crystalia_tower/store/sqlite/, and subprocess only by
src/crystalia_tower/launch.py. Nothing is swallowed either -- a caught
exception is re-raised, translated to a named error type, or surfaced. There is
no fourth option.
Licence
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file crystalia_tower-0.0.8.tar.gz.
File metadata
- Download URL: crystalia_tower-0.0.8.tar.gz
- Upload date:
- Size: 410.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8e546ce474cdcdc2702ce3cf9d6a7f0e2ba947c5216d3a182bdbfff7faa92797
|
|
| MD5 |
6ffdd7e2bdba23626a152a96755438fe
|
|
| BLAKE2b-256 |
53b942bdca88f8551705aa95fe374ac0421c4b4d86bab0d318cc87a3ce9e2fa5
|
File details
Details for the file crystalia_tower-0.0.8-py3-none-any.whl.
File metadata
- Download URL: crystalia_tower-0.0.8-py3-none-any.whl
- Upload date:
- Size: 136.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8e0e562fbb61da15a9f5eddb024430343f9dbe0c1f9a3294590be56c3e645826
|
|
| MD5 |
29f3b9f7babedb9545d2f228e59a1944
|
|
| BLAKE2b-256 |
dcb7454d586c1d69b999bc49cbf82df71cde2e48e8e5d5217d09b4f92bf7a2e1
|