Skip to main content

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

crystalia_tower-0.0.8.tar.gz (410.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

crystalia_tower-0.0.8-py3-none-any.whl (136.3 kB view details)

Uploaded Python 3

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

Hashes for crystalia_tower-0.0.8.tar.gz
Algorithm Hash digest
SHA256 8e546ce474cdcdc2702ce3cf9d6a7f0e2ba947c5216d3a182bdbfff7faa92797
MD5 6ffdd7e2bdba23626a152a96755438fe
BLAKE2b-256 53b942bdca88f8551705aa95fe374ac0421c4b4d86bab0d318cc87a3ce9e2fa5

See more details on using hashes here.

File details

Details for the file crystalia_tower-0.0.8-py3-none-any.whl.

File metadata

File hashes

Hashes for crystalia_tower-0.0.8-py3-none-any.whl
Algorithm Hash digest
SHA256 8e0e562fbb61da15a9f5eddb024430343f9dbe0c1f9a3294590be56c3e645826
MD5 29f3b9f7babedb9545d2f228e59a1944
BLAKE2b-256 dcb7454d586c1d69b999bc49cbf82df71cde2e48e8e5d5217d09b4f92bf7a2e1

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.0.8 This release

2 files

0.0.7

2 files

0.0.6

2 files

0.0.5

2 files

0.0.4

2 files

0.0.3

2 files

0.0.2

2 files

0.0.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page