← חזרה לכל השלבים

Orion Studio — Architecture (P5, stage: ARCHITECTURE)

Owner: architecture agent. Autonomous (P6). Upstream: specs/spec.md. Downstream: TASKS.
Decisions are technical and documented with reasoning (Ben delegates the technical).
Budgets are numbers. Inherits memory: brand-tokens-as-code, js-budget-gate,
aa-on-brand-color, aa-proof-kinetics (NOT wrangler-node-pin — see §7).
Designed on top of a deep reference study (§A) — embed, don't reinvent.

A · Reference study — patterns borrowed (read before §1)

Four reference repos shallow-cloned and read directly (not web-search synthesis). Concrete,

attributed patterns we build on. Embed/borrow the proven mechanics; reinvent nothing.

A.1 hermes-webui — our floor/template (vanilla, no-build, 3-panel, passkey)

*Source: github.com/nesquena/hermes-webui (read at /tmp/ref-hermes). Factual: Python**stdlib

http.server only, deps = pyyaml + cryptography, WebAuthn hand-rolled** on cryptography.*

.workspace-panel; panel state driven by documentElement.dataset.workspacePanel set

pre-paint (index.html:27,157,172,383). Adopt the rail + dataset-driven panel toggle.

text/event-stream + X-Accel-Buffering:no + : heartbeat, never Connection: close

on the stream (route_approvals.py:51,89; routes.py:9985,10061). Adopt verbatim.

state for all secrets (auth.py:418-462; passkeys.py:69-82). Adopt for our session +

passkey-credential store.

approval_id) (route_approvals.py:106; index.html:465). Bank for tier-2 (Review).

(server.py:33; helpers.py:209). Adopt the redaction idea.

by domain from day one; it's chat-first — we invert to instrument-first** (live state is

the hero, not conversation); tighten CORS/CSRF (it's * on OPTIONS).

A.2 ttyd — VPS terminal embed (we embed, never build a terminal)

Source: github.com/tsl0922/ttyd (read at /tmp/ref-ttyd, src/http.c, man page).

same-origin iframe under a Caddy sub-path works with no override.

when iframing cross-path (else the WS drops); avoid -a/--url-arg (injection surface).

A.3 Langfuse — trace waterfall algorithm (reimplement in plain JS)

Source: github.com/langfuse/langfuse (read at /tmp/ref-langfuse). Three pure passes —

we reimplement, we do not import (drop tRPC/Next/ClickHouse/Decimal.js):

rows (parent not in id-set) become roots (lib/tree-building.ts:88,110-175).

(tree-building.ts:183-312).

startOffset = ((start−traceStart)/totalSpan)W, barWidth = (latency/totalSpan)W; position

by margin-left+width; zero-width floor ||10px, dashed border = open span (TimelineBar.tsx:54,141).

collapsed set (timeline-flattening.ts:27-139; TimelineRow.tsx). Metrics computed once, not per render.

amber/graphite** by swapping classes (lib/helpers.ts:159-185).

A.4 OpenHands — panel layout (we borrow layout, not its terminal)

Source: github.com/All-Hands-AI/OpenHands (read at /tmp/ref-openhands). We embed ttyd, so we

skip its xterm bridge; we borrow the resizable-panel mechanics:

as inline width:X% — pure DOM, portable to vanilla (hooks/use-resizable-panels.ts:36).

disable transitions while dragging (ui/resize-handle.tsx:11). Persist width to localStorage

(use-resizable-panels.ts:17,54).

for a no-build cockpit that iframes ttyd.


1 · Confirmed stack (with reasoning)

Layer Choice Why
FrontendVanilla JS, no build — native ES modules + tokens-as-code CSShermes ethos, but ES modules avoid hermes' global-window accretion (A.1 diverge); per-domain files, boot.js orchestrator.
ServerFastAPI (Python 3.11+), routes split by domainOrion's stack (one language on the VPS); we use a WebAuthn library + split routes rather than hermes' hand-rolled 11k-line monolith (A.1).
Live updatesSSE (not WebSockets)hermes pattern (A.1); read-only observe fits a one-way stream; simpler than WS.
Terminalttyd embedded via same-origin iframe under /terminalA.2 — ttyd ships its own xterm; nothing to build.
TLS / proxyCaddy (auto-TLS), fronts Studio + ttyd same-origintrivial config; enables the same-origin iframe (A.2).
NetworkTailscale (WireGuard mesh)zero open public ports (NFR-5).
App authWebAuthn / passkey (py_webauthn lib) + HMAC-signed session cookie, 0600 storehermes pattern (A.1) but library-backed, not hand-rolled.
HTTP clienthttpx (async) → Observe API + Directusread-only proxy; no DB driver to Orion.
ContainerDocker + docker-compose, non-roothermes/Orion ethos (A.1 Dockerfile shape).

2 · The P10-clean seam — how the face reaches Orion's truth

Decision: the Studio server is a **thin read-proxy that calls the Orion Observe API over HTTP

(Tailscale) — it does not** import Orion code or open Orion's Postgres. The contract

(spec.md §4.1) is the seam; the server needs only the Observe API URL + token, no DB creds.

SSE from the Observe API (if present) is passed through to the browser via our own SSE

endpoint (A.1 hardening). The D-1 stub is a FixtureObserveClient with the same interface.


operator ──passkey──▶ Studio (FastAPI + vanilla ES-module JS)
                          │  httpx (read-only, Tailscale)        SSE passthrough ▲
                          ├──▶ Orion Observe API   (runs, traces)   [Orion-owned, D-1]
                          ├──▶ Directus REST       (product docs)   [read-only token]
                          └──▶ repo files          (memory/, products/*)
   workspace panel ──same-origin iframe──▶ /terminal ─Caddy▶ ttyd(127.0.0.1:7681) ─▶ shell
                                          [Tailscale · Caddy TLS · passkey · ttyd -c]

3 · Repo home — decided

Decision: authored in ai-studio/products/orion-studio/implementation/ (PRISM product of

record), deployed beside orion-os on the VPS, coupled to Orion only via the Observe API.

This realizes the brief's locked "both": *authored in PRISM, co-located at runtime, API-coupled

(not DB-coupled).* Keeps the PRISM audit trail, honors P10, runs at Orion's side for low latency.

4 · "No parallel store" — the one allowed local store, scoped

The Studio persists exactly one thing: the operator's passkey credential + session map

(JSON 0600, A.1 pattern). This is the Studio's own auth data, not Orion/PRISM truth — it does

not violate FR-5. Orion/PRISM data stays read-through; an optional in-memory cache (TTL ≤ 3s)

for the runs list is never authoritative, never persisted. **No Orion-data persistence layer

exists in the codebase** (AC-Trust).

5 · Project structure (refined from A.1 + A.4)


products/orion-studio/implementation/
  server/                # FastAPI, routes split by domain (NOT a monolith — A.1 diverge)
    main.py              # app wiring + static host
    config.py            # pydantic Settings (env-driven, Orion pattern)
    routes/observe.py    # /api/runs, /api/runs/{id}, /api/runs/stream (SSE passthrough)
    routes/prism.py      # /api/products, /api/memory  (repo + Directus)
    routes/auth.py       # /api/auth/* WebAuthn register/verify
    observe_client.py    # httpx → Observe API; FixtureObserveClient (D-1 stub), same interface
    directus_client.py   # httpx → Directus (read-only)
    repo_reader.py       # memory/ + products/* (FR-3)
    session.py           # HMAC-signed cookie + 0600 store, lazy-prune (A.1)
    sse.py               # bounded queue, heartbeat, no Connection:close (A.1)
  web/                   # NO build — native ES modules
    index.html           # .rail / .sidebar / .main / .workspace-panel (A.1); ?v= cache-bust
    css/tokens.css       # brand.md tokens VERBATIM (single source of truth)
    css/cockpit.css      # 3-panel layout + instrument styles
    js/boot.js           # orchestrator (loaded last)
    js/router.js  js/runs.js  js/trace.js  js/prism.js  js/terminal.js
    js/panels.js         # resizable split + localStorage width (A.4)
    js/motion.js         # breathe/tick, reduced-motion gate (aa-proof-kinetics)
    js/sse.js            # EventSource {withCredentials:true} + polling fallback (A.1)
    fonts/               # IBM Plex Sans + Mono (self-hosted .woff2)
  fixtures/observe/      # §4.1 contract fixtures (D-1 stub)
  deploy/
    Dockerfile  docker-compose.yml  Caddyfile  tailscale.md
  tests/                 # contract tests + AC checks (a11y/budget/drift/port/auth/resilience)
  README.md

trace.js implements A.3's 3-pass tree-build + two timing formulas; panels.js implements A.4's

resize math. The terminal is an iframe — no xterm code of our own.

6 · Budgets (numbers — EVALUATE enforces)

7 · Deploy — self-hosted VPS, NOT Cloudflare (documented divergence)

Decision: Docker on the VPS, fronted by Caddy, reached over Tailscale. A static

Pages deploy can't host ttyd or reach a private VPS, and the Studio must sit beside Orion's data —

so wrangler-node-pin does not apply (first PRISM web product that is self-hosted, not Pages).

ttyd (A.2):


ttyd -i 127.0.0.1 -p 7681 -b /terminal -W -c "$TTYD_USER:$TTYD_PASS" -t disableLeaveAlert=true bash

Caddyfile (A.2 — same origin so the iframe is same-origin):


studio.<tailnet-name>.ts.net {
    handle_path /terminal/* {
        reverse_proxy 127.0.0.1:7681 {
            header_up Host {host}
            header_up X-Forwarded-Proto {scheme}
        }
    }
    reverse_proxy /* 127.0.0.1:8000   # FastAPI Studio
}

Auth layers (outer→inner, each fails closed): Tailscale → Caddy TLS → passkey (app) → ttyd -c.

-O check-origin stays OFF (iframe is cross-path); localhost mode binds 127.0.0.1 only.

8 · Failure & resilience (NFR-7)

Each client (observe_client, directus_client, ttyd iframe) maps unreachable/error → a typed

calm-failure state; server returns 503 + {source, reachable:false}; UI renders the right

calm message per panel (FR-1.4) — never a spinner-forever or blank.

9 · Testing strategy

all routes (AC-Sec-2), ≤1 accent/viewport scan (AC-Calm), AA + keyboard + reduced-motion

(AC-A11y), JS-budget gate, resilience 3-case (AC-Resilience).

(pure → unit-testable); visual/brand checks at REVIEW.

10 · Dependencies (from spec §6 — corrected 2026-06-13)

Real dependency = Orion builds the Observe API (Observe tier, Orion-owned, P10) per

architecture/observe-api-contract.md. Fixtures (shaped to the real agent_runs/tasks/

shadow_logs columns) unblock the entire build; live cutover = config flip. PRISM proposes a

published schema + drift-guard mirroring prism_task.v1.

11 · Knowledge candidates (for KNOWLEDGE stage)

shipped brand.md, not memory** (caught the Space Grotesk overlap at the BRAND gate).