Space StationOpen the station

Space Windows

A Space Window is Processor → Renderer. The processor is JavaScript that turns records into one small JSON document — the SiliconJSON — and exposes tools. The renderer is an HTML page in a sandboxed iframe that shows it. Carbons look at the renderer; silicons read the SiliconJSON and call the tools.

Create a window under Space Windows (a name under 20 characters), or with spacestation windows create "Orders". The window page shows a prompt for an agent with everything an agent needs to write both files, and Add code, which publishes a named version. Windows are version-managed; every version has a name and a created_by, and spacestation windows rm <id> takes the window and all of them.

The processor

const subscriptions = {
  recent: {
    triggers: [{ table: "orders", where: "record.amount::Float64 > 5" }],   // where is optional
    sql: "SELECT record.id::String AS id, record.amount::Float64 AS amount FROM orders ORDER BY event_ts_ms DESC LIMIT 50",
    mode: "delta",                                                          // rows = only the new ones
    onTrigger: (json, rows) => ({ ...json, recent: [...rows, ...(json.recent ?? [])].slice(0, 50) }),
  },
  totals: {
    triggers: [{ table: "orders" }],
    sql: "SELECT toInt32(count()) AS n, sum(record.amount::Float64) AS revenue FROM orders",
    mode: "snapshot",                                                       // rows = the whole table
    onTrigger: (json, rows) => ({ ...json, totals: rows[0] }),
  },
};

// init runs every subscription's query once, through the same onTrigger, so a freshly published
// window shows data before the next row arrives. (An `init: (json) => json` shows nothing until then.)
async function rebuild(json) {
  for (const sub of Object.values(subscriptions)) json = await sub.onTrigger(json, await mission_control.query(sub.sql));
  return json;
}

export default defineProcessor({
  init: () => rebuild({}),                    // runs first, once; mission_control.query(sql) resolves to the rows
  subscriptions,

  tools: {
    order_detail: {
      args: { order_id: "string" },
      run: (json, { order_id }) => json.recent?.find((r) => r.id === order_id) ?? null,
    },
    filter_orders: {
      args: { min_amount: "number", limit: "number?" },
      run: (json, { min_amount, limit = 10 }) => json.recent.filter((r) => r.amount >= min_amount).slice(0, limit),
    },
  },
});

Inside the processor there is mission_control.query(sql), mission_control.data (cursors and watermarks per table) and mission_control.json. Nothing else: no fetch, no imports, no credentials, no DOM. Processor code is untrusted and runs in an opaque-origin iframe (in the browser) or a permission-less child process (under Node).

mission_control.query(sql) returns a promise of the rows — an array of objects, exactly what onTrigger receives — and not {rows, watermarks}: the runtime keeps the watermarks for you in mission_control.data.tables[<table>] ({cursor, watermark}). A refused query rejects with {code, message}. Column values are what SQL describes: JSON paths need a cast, and every 64-bit integer arrives as a string — count(), countIf(), sum() of an integer, ::Int64 — except cursor, event_ts_ms and registered_ts_ms, which the runtime turns into numbers before your code sees a row. Wrap the rest (toInt32(count()) AS n, toFloat64(sum(record.qty::Int64))) or Number() it in JavaScript.

init and cursors

When a window starts, init(json) runs before any subscription is registered. json is the last SiliconJSON the server has for this window, or {}. Every query inside init is bounded to the tables' current watermarks (the highest cursor written so far), and after init the subscriptions continue from exactly those cursors — no row is seen twice and none is skipped.

Init is what a freshly published window shows. Subscriptions run only when a new row arrives, so an init that returns its argument unchanged leaves a new window — or a new version — empty until the next record, which looks like a broken window when the table is quiet. Init should rebuild everything the subscriptions maintain: run each subscription's SQL once and feed it to the same onTrigger, as rebuild does above, so a window is complete the moment it opens and a live update and a cold start can never disagree about the shape of the document.

Subscriptions: delta vs snapshot

A subscription's triggers say when to run: any new row in table that matches where (or any row when where is absent). Its sql says what to fetch, in one of two modes:

  • delta — the SQL sees only rows with cursor in (last cursor, watermark] for every trigger table. Ideal for "append the new orders". After a successful run the cursor moves.
  • snapshot — the SQL sees the whole table. Ideal for totals and top-N.

Subscriptions run one at a time, in definition order; several triggers for the same subscription while another is running are combined into one run. onTrigger (queries included) has 10 s. If it throws, times out, returns something that is not an object or more than 64 KB, that run is a dev error: the SiliconJSON and the cursors stay unchanged and the queue moves on.

If the connection drops, the runtime reconnects, re-subscribes and catches up from the cursors it holds — cursor management is the client's job, and the runtime does it for you.

Tools

args is a schema: "string", "number" (finite), "boolean", "object" (non-null, non-array), "array"; a ? suffix makes an argument optional. Unknown keys are refused. run gets a copy of the SiliconJSON and its return value (any JSON ≤ 64 KB) is the result; a tool never replaces the SiliconJSON. Tools share the subscription queue and the 10 s limit.

The 64 KB rule

The SiliconJSON, tool arguments and tool results are each at most 64 KB (UTF-8 bytes of the JSON text). Keep aggregates and the few rows a view needs, never raw dumps. It is "as close to the final render as possible" by design: the renderer should mostly display it.

The renderer

The renderer is an <iframe sandbox="allow-scripts">: it has no cookies and no access to the parent page, but it may load any CDN and call any third-party service. It gets the SiliconJSON, the tools and the metadata, and an event when the JSON changes. Inside it:

mission_control.…
json the SiliconJSON
metadata {processor_version, renderer_version, produced_at, is_live}
stale !metadata.is_live
tools.<name>(args) runs a tool; async, type-checked against the schema
on("update", cb) called after every change
mount(selector) creates a Vue 3 app with mission_control and stale in scope (when Vue is on the page)

The renderer cannot query or subscribe.

Default and preferred stack: Vue 3 + D3, both from a CDN.

<div id="app">
  <h1>{{ mission_control.json.totals?.n }} orders</h1>
  <p v-if="stale">as of {{ mission_control.metadata.produced_at }}</p>
  <ul><li v-for="o in mission_control.json.recent" :key="o.id">{{ o.id }} — ${{ o.amount }}</li></ul>
  <svg id="chart" width="600" height="160"></svg>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.5.13/vue.global.prod.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
<script>
  mission_control.mount("#app");

  mission_control.on("update", () => {
    const rows = mission_control.json.recent ?? [];
    const x = d3.scaleBand().domain(rows.map((r) => r.id)).range([0, 600]).padding(0.1);
    const y = d3.scaleLinear().domain([0, d3.max(rows, (r) => r.amount) ?? 0]).range([160, 0]);
    d3.select("#chart").selectAll("rect").data(rows, (r) => r.id).join("rect")
      .attr("x", (r) => x(r.id)).attr("width", x.bandwidth())
      .attr("y", (r) => y(r.amount)).attr("height", (r) => 160 - y(r.amount));
  });

  // await mission_control.tools.filter_orders({ min_amount: 5 });
</script>

Developing locally

The npm package @teamofsilicons/space-station ships the runtime and space-station-dev, a dev server that mimics production exactly: the same runtime, the same processor/renderer boundary, the same queries — only the credential differs.

npm i -g @teamofsilicons/space-station
mkdir orders-board && cd orders-board
cat > .env <<'EOF'
SPACE_STATION_URL=https://space.example.com
SPACE_STATION_ACCESS_TOKEN=spacewindow-…
SPACE_STATION_WINDOW=win_…          # optional: seeds the page from a published window
EOF
space-station-dev            # serves ./processor.js + ./renderer.html at http://127.0.0.1:4747

SPACE_STATION_ACCESS_TOKEN is the access token shown at the top of every window page. It is yours (one per account, rotatable there), not the window's, and it lets mission control run queries on your behalf. The dev server is the only thing that holds it: your code never sees it, the page reloads on change, and .env is never served. Errors from the processor and the renderer show in the dev page; in the app, Option+Shift+D opens the same panel.

space-station-dev notify <notification-id> runs a notification against its last known trigger (or tells you there was none) — see Notifications.

Publishing and the secret check

space-station-dev publish --window <window-id> --name v1
spacestation windows publish <window-id> --name v1 --processor processor.js --renderer renderer.html

or paste both files into Add code on the window page with a version name. Either way the server scans the code for anything shaped like a credential — spacewindow-…, apikey-…, whsec-…, table-{id}-… and Silicon Accounts tokens — including inside comments, and refuses the version with secret_in_code. Dev code that reads the token from anywhere is not publishable by construction: the runtime injects credentials, your code never touches them.

The published version becomes the window's current version. The owning account opening the window runs it in their browser; the server keeps the last SiliconJSON the window produced (one per window, whichever version wrote it last) so a fresh open shows the latest state at once (with is_live false until the run is connected).

From a terminal

A window is a renderer and a document, and the document half needs no browser:

spacestation windows run <id>              run the published processor here; idle 10 min → inactive
spacestation windows json <id>             the cached SiliconJSON and its metadata
spacestation windows tool <id> filter_orders '{"min_amount": 5}'
spacestation windows code <id>             the current version's processor and renderer
spacestation windows open <id>             print this window's page, and open it

run starts the same host the app runs, with the processor in a credential-less Node child, and keeps the server's copy of the SiliconJSON current while it runs. json and tool read that copy without starting anything. ls, get and edit print a summary — id, name, the current version's name and author — and code prints the code, so a listing stays readable and a silicon that wants to read one of its windows asks for exactly that. The renderer is the one part a terminal cannot show, so open hands it to a browser instead — see the CLI and the Rust package.

The prompt for an agent

The window page has a copyable prompt that packs this page and SQL into instructions for an agent — the window's id and name, the account's table ids, both APIs, the limits, the dev loop and the publish command. Hand it to any agent; paste what comes back.