Skip to main content

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 localhost Host. This is the floor under everything else.
  • host+origin — plus: an Origin header, if present, must be loopback. A REST client sends none, which counts as safe.
  • host+origin+json, host+json — plus Content-Type: application/json, or 415.
  • host+json+optin — plus the server must run with SPECTRO_ALLOW_SPAWN. POST /api/fleet/nodes only. 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.10 also 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.jsonl and .state.jsonl format 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

spectroscope-0.1.0.tar.gz (136.6 kB view details)

Uploaded Source

Built Distribution

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

spectroscope-0.1.0-py3-none-any.whl (80.6 kB view details)

Uploaded Python 3

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

Hashes for spectroscope-0.1.0.tar.gz
Algorithm Hash digest
SHA256 1690938c6999bdce6b5f870aaf06ff82aefa3ad1e5004c681ff263e1ac8dcc10
MD5 71e0b5f6b77ef61eb7bac10a2381ec63
BLAKE2b-256 ed2e492712c19142265757a2e6178746caafa82d1318691a506aed17866fa5fc

See more details on using hashes here.

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

Hashes for spectroscope-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1992daac8214a802782582e844bb47d8bf87d85247481e20173517d048dc7700
MD5 33d57c717957fa83c302dce3a3219f0d
BLAKE2b-256 c6e33a3d021dc006a71ba2aabe2329504a3cfe2d0c5037e1baa189b163c6f862

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page