spectroscope — the agent orchestrator you can watch
A graph runtime whose topology is written to disk before the first token moves, and a client that can name the server holding your runs by address instead of assuming it is on this machine.
pip install spectroscope
Nothing comes with it. The engine is stdlib, and so is the client — urllib
rather than requests or httpx. That is a promise about your dependency tree,
not thrift.
Where the engine came from
src/spectroscope/graph/ was copied out of the spectroscope Python edition on
2026-08-11, byte-identical that day: 6 modules, 3594 lines, counted with
wc -l src/spectroscope/graph/*.py.
That is provenance, not an instruction. Since 2026-08-12 this package owns the engine — edit it here, and the suite in this repository is what proves it.
There was a tool that diffed the two trees and failed the suite when they parted
company. It was withdrawn along with that decision, because it could no longer
return a pass: it compared against a sibling checkout that no public clone and
no pip install has ever had, and it reported that as "nothing was compared —
this is not a pass". A guard that can only skip reads like coverage and delivers
none. src/spectroscope/graph/VENDORED.md records what it did and what is
consequently no longer watched.
Why a graph at all
An agent loop can only be reconstructed afterwards, out of its logs. A graph's
shape is fixed the moment compile() returns, so the machine can be drawn
first and then lit up as the run walks it. Everything here serves that: the
builder refuses a graph it cannot draw, the executor runs in supersteps so a run
replays the same way twice, and every node entry, exit and edge taken lands in a
JSONL artifact beside your session file.
import asyncio
from operator import add
from typing import Annotated, TypedDict
from spectroscope import END, START, GraphArtifact, InMemorySaver, StateGraph
class State(TypedDict, total=False):
frage: str
treffer: Annotated[list[str], add] # the reducer is read off the annotation
antwort: str
def suchen(state): ...
def pruefen(state): ...
def entscheiden(state) -> str: ...
g = StateGraph(State)
g.add_node("suchen", suchen); g.add_node("pruefen", pruefen)
g.add_edge(START, "suchen"); g.add_edge("suchen", "pruefen")
g.add_conditional_edges("pruefen", entscheiden,
{"antworten": "antworten", "erweitern": "erweitern"})
compiled = g.compile(checkpointer=InMemorySaver(),
sink=GraphArtifact("lauf.graph.jsonl"))
final = asyncio.run(compiled.ainvoke({"frage": "wo"},
{"configurable": {"thread_id": "t1"}}))
sink= and state= are the two additions to the LangGraph signature, and both
are keyword-only so nothing positional can drift into them. The runtime is
async: ainvoke, astream, aget_state. There is no sync façade, because a
fake one would have to spin its own event loop and would then deadlock inside
any caller that already has one.
What lands in the file
lauf.graph.jsonl gets the topology at compile time, then one record per node
entry, exit and edge taken. It carries keys, never values — which is what
makes it safe to attach to a bug report.
Values are a second file and they are off unless you ask, in one line you can find again:
from spectroscope import StatePolicy, artifacts
compiled = g.compile(sink=artifacts("lauf.jsonl", state=StatePolicy.summary()),
state=StatePolicy.summary())
Four tiers — off, summary, sample, full. Measured against a real
eight-node CRAG turn, as a share of the run's own state volume: off 2.78 %,
summary 6.53 %, sample 16.99 %, full 103.40 %. A truncated value never
looks like a complete one: every ceiling that fires replaces the value with a
self-describing marker carrying the true size and the limit that hit.
A library that starts writing your document corpus to disk because it was
upgraded is a data incident, not a feature. That is why the default is off and
why it stays there.
Reading it back
examples/graphview/graphview.html is a single file with no CDN, no build and
no network calls. Open it in a browser, drop a .graph.jsonl and its
.state.jsonl on it, and the run replays over the drawing. Two things it does
on purpose that a trace viewer does not: the path not taken stays in the
picture and only recedes, and back edges bow visibly out of the line so a
correction loop reads as a loop. Right and middle mouse buttons pan, left
selects.
Two runnable demos write artifacts for it into examples/out/:
./.venv/bin/python examples/graph_topology_demo.py # the drawing and the walk
./.venv/bin/python examples/graph_payload_demo.py # the four value tiers, side by side
The viewer and both demos came from the Python edition's spectro/examples/ on
2026-08-11 and are maintained here now.
Running an existing LangGraph application on this engine
compat/ is a drop-in langgraph namespace. Put the directory on the path and
an application keeps its imports exactly as written:
from langgraph.graph import END, START, StateGraph
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.config import get_stream_writer
Five files, each one import line, no adapters — tests/test_compat_imports.py
asserts identity (is), so a shim that wrapped anything would fail. It is never
published to PyPI under that name; langgraph belongs to LangChain, Inc. Read
compat/README.md before switching: the shim is a regular package and
therefore shadows the whole installed namespace, langgraph.prebuilt and
langgraph.types included.
What this is not: a LangGraph reimplementation. Send, Command,
interrupt(), Store, subgraphs, retry policies, add_messages and the
platform server API are absent, each with a reason on the record. An application
that touches one fails at import — loudly, at start, which is the intended
failure mode.
The evidence, such as it is: one real application's suite (130 tests) runs green on this engine across three rounds of changes, and 98 behaviours were compared against LangGraph 1.2.9 with 89 identical and the rest written down as divergences. Both measurements were taken in the spectroscope Python edition on 2026-08-10 and are quoted here rather than reproduced — nothing in this package re-runs them.
The SDK half: a server named by address
from spectroscope import SpectroscopeClient
server = SpectroscopeClient("box.local:8080")
print(server.probe().summary)
for session in server.sessions():
print(session["id"])
Accepted forms: http://box:8080, https://box:8443, box:8080, [::1]:8080,
localhost (port defaults to 8080).
Read this before pointing it off-box
spectro-server fences its entire REST surface to a local origin. One
servlet filter in the server refuses every /api path unless the TCP peer is
loopback and the Host header names localhost. Exactly one path is left
open on purpose:
| Endpoints | Fence | Works against a remote address? |
|---|---|---|
GET /api/health |
none |
yes |
| the other 58 | host and stricter |
no — blank 404 |
Fence classes, in the server's own vocabulary, from the endpoint collection published with the server (measured 2026-08-04, 59 endpoints, probed with curl against a live server rather than read off the controllers):
host— loopback peer plus a localhostHost. This is the floor under everything else.host+origin— plus: anOriginheader, if present, must be loopback. A REST client sends none, which counts as safe.host+origin+json,host+json— plusContent-Type: application/json, or 415.host+json+optin— plus the server must run withSPECTRO_ALLOW_SPAWN.POST /api/fleet/nodesonly. Disabled and fenced-out both answer 404: there is no enablement oracle.
Every refusal is a blank 404, never a 403, so a refused caller cannot fingerprint the server. The cost of that lands here: a fenced-out call looks exactly like a session that is not there. So the client does three things rather than guess.
It names the fence when the address cannot pass it.
server = SpectroscopeClient("box.local:8080")
server.sessions()
# LocalFenceError: 404 /api/sessions on http://box.local:8080: every /api path
# except /api/health is fenced to a loopback peer carrying a localhost Host
# header (fence class 'host', table measured 2026-08-04), and 'box.local' is not
# one of the names the server accepts. This endpoint has no 404 of its own, so
# the fence is what answered. Open a tunnel:
# ssh -N -L 8080:localhost:8080 <user>@box.local
# then point the client at http://localhost:8080
LocalFenceError subclasses NotFound, so code that already handles a 404 keeps
working.
It admits when it is inferring. error.proven is True only for
GET /api/sessions and GET /api/fleet, whose handlers have no 404 of their own
— there a 404 must be the filter. Everywhere else the fence is the likely cause
and the exception says so.
It measures instead of arguing. probe() sends two requests, /api/health
(open, so it separates "unreachable" from "refused") and /api/sessions (cannot
miss on its own), and returns a FenceReport with reachable, fenced and a
one-line summary. Neither request writes anything.
How to actually reach a remote server
An SSH local forward, and nothing else:
ssh -N -L 8080:localhost:8080 you@box.local
# then
SpectroscopeClient("localhost:8080")
It satisfies both halves of the fence without weakening the server: the tunnel's
far end dials 127.0.0.1, so the peer is loopback, and your URL says localhost,
so the Host is right.
There is no Host override in this client and there will not be one. Forging
the header cannot work — the peer half is decided by routing, and no header moves
a packet's source address. Offering the knob would only teach a workaround that
fails.
If an operator opens the port to a network instead, know what that buys the
network: spectro-server has no authentication at all. The fence is the
access model. An open port is unauthenticated access to your sessions, your
settings, the API-key save endpoint and — with SPECTRO_ALLOW_SPAWN — process
spawning. If you front it with a proxy that adds auth, pass its header through:
SpectroscopeClient("box.local:443", headers={"Authorization": "Bearer …"})
What the client does not speak
The two WebSocket endpoints, /ws (the run stream) and /ws/shell (the PTY).
They are origin-checked at handshake time and are not REST. A dependency-free
client has no WebSocket implementation, and a half-working stream on the surface
would be worse than an absent one.
Development
git clone … && cd spectroscope-sdk-python
python3 -m venv .venv && ./.venv/bin/pip install -e ".[dev]"
./.venv/bin/python -m pytest -q
A conftest.py puts src/ on the path, so a plain python -m pytest -q in a
fresh clone runs the suite without an install. It prepends: a spectroscope
installed elsewhere in the same interpreter would otherwise answer first and the
suite would quietly test the wrong engine.
Nothing runs this suite but a person typing the command. There is one
workflow, .github/workflows/release.yml, and it builds and publishes on a
version tag — it does not run the tests. So every number here was measured by
hand and carries the date and the command that produced it, because a number
nobody re-measures rots the next time the tree is touched.
On 2026-08-12, python -m pytest -q on 3.14.6:
515 collected · 513 passed · 2 skipped
The two skips are deliberate. They resolve a reducer schema out of a real production application's own file, which is not public, so they run only when you point them at one:
SPECTROSCOPE_REFERENCE_STATE=/path/to/backend/graph/state.py python -m pytest -q
Five interpreters were run on 2026-08-11, when the suite stood at 480 cases: 3.10.20, 3.11.15, 3.12.13, 3.13.13 and 3.14.6, 480 passing on each. Only 3.14.6 has been re-run since, so the other four attest to the tree as it was that day and not to this one.
The editable install was checked on 2026-08-11 rather than assumed: a fresh
venv, pip install -e ., then an import of spectroscope from a different
working directory — which is what proves the install rather than the path insert
in conftest.py.
Known gaps, stated rather than implied
- L3, the LLM wire. A third artifact,
<stem>.llm.jsonl, recording the real provider exchange at the socket, is specified and has a test module written against it. The module it tests does not exist. Nothing here emits.llm.jsonl. - Python ceiling, not floor.
requires-python = ">=3.10"is not inherited on faith — the floor was checked by running the suite on 3.10.20, 3.11.15, 3.12.13 and 3.13.13 as well as 3.14.6 (2026-08-11). The open top end is not covered:>=3.10also promises 3.15 and later, and no one has run those. Nothing re-checks any of this automatically. - No engine drift check. See "Where the engine came from". Nothing notices
if this engine and the Python edition's disagree about the
.graph.jsonland.state.jsonlformat they both write.
License
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 spectroscope-0.1.0.tar.gz.
File metadata
- Download URL: spectroscope-0.1.0.tar.gz
- Upload date:
- Size: 136.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1690938c6999bdce6b5f870aaf06ff82aefa3ad1e5004c681ff263e1ac8dcc10
|
|
| MD5 |
71e0b5f6b77ef61eb7bac10a2381ec63
|
|
| BLAKE2b-256 |
ed2e492712c19142265757a2e6178746caafa82d1318691a506aed17866fa5fc
|
File details
Details for the file spectroscope-0.1.0-py3-none-any.whl.
File metadata
- Download URL: spectroscope-0.1.0-py3-none-any.whl
- Upload date:
- Size: 80.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1992daac8214a802782582e844bb47d8bf87d85247481e20173517d048dc7700
|
|
| MD5 |
33d57c717957fa83c302dce3a3219f0d
|
|
| BLAKE2b-256 |
c6e33a3d021dc006a71ba2aabe2329504a3cfe2d0c5037e1baa189b163c6f862
|