Space StationOpen the station

The Rust package

space-station is the interface. Everything Space Station can do is a method on this crate: the CLI is a clap tree over it with no capability of its own, and this app is a subset of the same surface. If you are writing code rather than typing commands, this is the whole product.

[dependencies]
space-station = "0.5"

The package is published on crates.io. MSRV 1.89. Version 0.5 removes automatic telemetry and machine metadata collection; update older installations to use this behavior.

It is synchronous throughout — every call is one request and one answer — and it never opens a browser. Supported platforms are macOS, Linux, and Windows 10 1803 or newer, using native local sockets and platform file locks.

Two halves

Recording is SpaceClient: a table key in, JSON records out, non-blocking, backed by the machine's daemon. Managing is Auth + Space: who you are, and one typed method per operation.

use space_station::{Auth, Space, SpaceClient};

// Recording: the table key that was shown once when the table was created.
let ss = SpaceClient::new("table-orders-…")?;
ss.record(serde_json::json!({"id": "o-42", "amount": 12.5}));

// Managing: the current Carbon or Silicon account.
// SLT from `silicon-accounts login --app spacestation -q`.
let url = space_station::default_url();
let auth = space_station::exchange(&url, &slt)?;
let space = Space::new(&url, auth)?;
let tables = space.tables()?;
let key = space.create_table("orders")?;
let rows = space.query("SELECT count() FROM orders", &Default::default())?;

Space::new takes the Space Station URL and an Auth. All calls use that credential's account; keep separate credentials and caches for different accounts. Account UUIDs are immutable, while c: and si: handles are display identifiers.

Stateless

The managing half keeps nothing. Auth is a value you build; Space is a function of (url, Auth); neither reads a file, the environment or the home directory, and neither writes. The same code with the same inputs does the same thing on any machine.

The one thing that changes over time — an Application session's refresh token rotates on every use, and presenting a stale one revokes the whole family — is not this crate's problem by construction: the session and its refresh live in the backend, which alone holds the Application secret, and an sscli- credential is a stable handle to it. The package never rotates anything, so there is nothing to emit or persist mid-run. Auth is Serialize/Deserialize, so persisting it verbatim is one serde_json::to_string.

The recording half is stateful on purpose — the spool and the daemon lock are what survive a crash — but never ambient: Builder::home and daemon::Config name the directory, and run_window takes the directory it may unpack the runtime into. The CLI is the one that stores each account context in <home>/accounts/<server-hash>/<profile>/auth.json; select one with --profile or $SPACE_STATION_PROFILE. The recording spool stays in the shared base home.

Auth

Auth is how you prove who you are — a value, never a file:

for credential
Auth::session(sscli) a carbon or a silicon signed in from a terminal the sscli- session the backend minted and refreshes
Auth::access_token(t) window and processor code a spacewindow- token
Auth::api_key(k) a program acting for the account an apikey- key

Auth::describe() says what it is and never what it holds; Debug prints the same. There is no constructor that takes a Silicon Accounts bearer, refresh token or Silicon STK.

Carbon browser flow. space_station::login(url, visit) opens a loopback listener and passes a sign-in URL to visit. The backend performs the OAuth/PKCE exchange and redirects a one-time handoff. The client verifies its state and redeems the handoff over POST for a session.

let auth = space_station::login(&space_station::default_url(), |link| {
    eprintln!("open this to sign in:\n{link}");
})?;

Silicon token flow. space_station::exchange(url, slt) posts the slt_ token issued by silicon-accounts login --app spacestation -q to /api/auth/session. It returns a session and its absolute expiry. The token can be exchanged once within two minutes.

The backend alone holds the app secret and refreshes Silicon Accounts access tokens. Browser and terminal sessions persist independently until expiration, revocation or logout. Temporary network and service errors do not clear the saved credential. Auth::expires_at() exposes the server session expiry when known.

See Credentials for what each one may do.

Space

One method per operation. Each returns a typed value or Error.

me()  app_url()                                          who you are, where the UI lives

tables()  table(id)  create_table(id) -> Key
rotate_table_key(id) -> Key   delete_table(id)   overview(window)

windows()  window(id)  create_window(name)  update_window(id, name)
delete_window(id)  versions(id)  publish(id, name, processor, renderer)  window_state(id)
run_window(id, runtime_dir, on_line)  window_tool(id, runtime_dir, name, args)  window_url(id)

notifications()  notification(id)  create_notification(def, recipients)
update_notification(id, def, recipients)  delete_notification(id)  events(id)
subscribe(id)  unsubscribe(id)  test_notification(id)

webhooks()  create_webhook(url) -> Key  delete_webhook(id)  set_silicon_webhook(url) -> Key  delete_silicon_webhook()
api_keys()  create_api_key(scopes) -> Key  delete_api_key(id)
access_token()  rotate_access_token()

query(sql, restrict)  dev_errors()  logout()

me() returns the account's kind, display id, immutable uuid, app, and session expiry. Each account owns its data. Key is a secret the server shows once; AccessToken can be read back. update_window and update_notification accept Options: None leaves that field alone. Every returned type implements Serialize.

delete_table, delete_window and delete_notification need the same access as reading the thing. A window takes its versions and its state with it, a notification its versions and its events, and a table its records — those by a ClickHouse mutation that runs after the call returns, so the id is free to reuse at once and a query in the next second may still see a few.

Reading records

let rows = space.query("SELECT count() AS n FROM orders", &Default::default())?;
println!("{} rows, watermarks {:?}", rows.rows.len(), rows.watermarks);

The second argument is a Restrict — BTreeMap<String, Bounds>, the cursor range to read each table over. Leave it Default::default() to see everything up to the current watermark; the watermarks actually used come back with the rows, so the next call can carry on from them. The SQL is the same read-only dialect the app and notifications use: see SQL. Rows are serde_json::Value objects exactly as ClickHouse emitted them, which means every 64-bit integer — cursor, event_ts_ms, registered_ts_ms, and any count(), sum() of an integer or ::Int64 — is a Value::String; as_str().parse::<u64>() it, or cast in SQL (toInt32(count())). The package coerces nothing.

Windows, and the pixels problem

run_window and window_tool need Node. They write the embedded runtime into the runtime_dir you name — <dir>/mission-control.js, only when its bytes differ — and spawn node on it with only PATH and SPACE_STATION_ACCESS_TOKEN in the environment; the processor itself is a further child with no credential at all. run_window(id, runtime_dir, on_line) streams every JSON and diagnostic output through separate callback variants and returns when the window goes idle:

let dir = std::env::temp_dir().join("space-station-runtime");
space.run_window("w_01", &dir, |event| match event {
    space_station::WindowOutput::Json(line) => println!("{line}"),
    space_station::WindowOutput::Diagnostic(line) => eprintln!("{line}"),
})?;
let detail = space.window_tool("w_01", &dir, "order_detail", &serde_json::json!({"order_id": "o_9"}))?;

What the crate deliberately does not do is draw. A renderer is an iframe, a chart is pixels, and neither belongs in a process with no screen — so window_url(id) returns {app}/a/{uuid}/windows/{id}, where {app} is where the UI lives according to GET /me, and you hand that to a browser. That is the rule for anything visual: the package returns a link, the app renders it.

Recording

use space_station::SpaceClient;

let ss = SpaceClient::builder(&key)
    .on_error(|e| eprintln!("space station: {e}"))
    .build()?;
ss.record(serde_json::json!({"id": "o-42", "amount": 12.5}));

record() stamps metadata, sanitises the value and pushes it into a bounded queue; a sender thread hands it to the machine's daemon, which spools it to disk and ships it over one WebSocket. It never blocks and never panics. flush() and Drop block — at most 5 s, or Builder::flush_timeout — until the in-process daemon has had everything this client recorded acked by the server, so a program that records and returns from main still delivers; when a separate spacestation daemon carries the records it waits only until they are in that daemon's spool, which outlives the program. flush() answers false when the bound ran out first, and the spool keeps the lines for the next daemon. Server-side rejections (Error::Rejected { record_id, code, reason }) reach the on_error of the client whose in-process daemon sent the batch; when a separate daemon is carrying the records, they are that process's to report (daemon status), and this hook never sees them. Builder also takes .url(), .home() and .flush_timeout(). daemon::status(home) says whether a daemon is listening for that home, where its socket is and how many records are still unacked; daemon::run(config) is the daemon itself, in the foreground. daemon::socket_path(home) is where it listens: <home>/daemon.sock when that fits a socket address (104/108 bytes on macOS/Linux), else <temp dir>/space-station-<hash of home>.sock, so a long home moves only the socket. The whole path — limits, metadata, rejection codes — is in Getting started.

Errors

One Error for both halves. Branch on the variant, and inside Api on code — never on a message:

variant
InvalidKey the string is not table-{id}-{32 hex}
SizeExceeded { bytes } · QueueFull a record refused before it left the process
Rejected { record_id, code, reason } the server's verdict on one record
Transport(..) Space Station could not be reached
Api { code, message } it answered, and said no; code is snake_case and stable — context_changed means the selected account changed
Local(..) nothing left the machine: no credential, no node, a secret in the code
Io(..) · Ws(..)

Environment

The crate reads no .env file and no variable on its own. Two helpers are there for a program that wants the conventions, and they are the only place the environment is consulted:

default_url() $SPACE_STATION_URL, else the public Space Station
default_home() $SILICON_HOME/.space-station, else $SPACE_STATION_HOME, else ~/.space-station — the shared base for the spool, lock and daemon socket. The CLI stores credentials below accounts/<server-hash>/<profile>/

The URL defaults to the public host, so point it at a local stack explicitly rather than by omission. Or skip the helpers and pass your own values to Space::new, exchange, login and SpaceClient::builder.

Next

CLI · Credentials · SQL · Space Windows · HTTP API