Das ist das Tool, das ein Claude-Chat aufruft, um in osFoundry einen Graph zu starten, so wie es der öffentliche MCP-Endpoint auflistet. Abgerufen mit einem nicht authentifizierten tools/list am 2026-11-08 und gekürzt:
{"name": "run_graph_session", "description": "Queue a Graph run on the owner's online desktop in a connected project. Retrieve progress and result with get_graph_run. The desktop can go offline, and local tool approvals still apply.", "inputSchema": { "properties": { "graph_session_id": {"type": "string"}, "arguments": {"type": "object"}, "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}}, "required": ["graph_session_id", "idempotency_key"], "additionalProperties": false}, "annotations": {"readOnlyHint": false, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:run"]}]} Es hat drei Geschwister. list_graph_sessions (project_id, limit 1–50) und get_graph_run (command_id) sind read-only unter osfoundry:read. cancel_graph_run (command_id) läuft unter osfoundry:run, wie der Start-Aufruf. Zwei echte Antworten von einem verbundenen Account, IDs entfernt:
list_graph_sessions {"project_id": "…"} → {"project_id": "…", "sessions": []} get_graph_run {"command_id": "<unknown>"} → error: Graph run not found or access removed Die leere Liste ist der Normalfall. Dort taucht nichts auf, bis der Besitzer eine Session vom Desktop aus freigibt. Das Verb in der Beschreibung ist "Queue": Das Tool kehrt zurück, bevor der Graph überhaupt gestartet ist. Der Rest dieses Beitrags handelt davon, warum das so ist und was es den Agenten auf der anderen Seite kostet.
Was ist eine Graph-Session, und warum kann der Server sie nicht einfach ausführen?
Ein Graph ist ein gespeichertes JavaScript-Programm, das die Fleet-Engine des Desktops ausführt: bedingte Pfade, parallele Agenten, begrenzte Retries, Aufrufe anderer Graphs. In apps/web/src-tauri/src/graph_config.rs ist run_graph_now das Tauri-Command, das der Graph-Screen verwendet. Es übergibt das Script an run_fleet_script, mit einer harten Token-Obergrenze, die die nächste Welle von Agenten ablehnt, sobald sie erreicht ist. Eine Graph-Session ist die dauerhafte Identität eines gespeicherten Graphs auf einem Gerät, gs_ plus 32 Hex-Zeichen, in der lokalen Tabelle graph_sessions.
Die Engine, die vom Script gestarteten Agenten, lokale Modelle, Ordner und die Freigabe-Einstellungen pro Tool liegen alle im Desktop-Prozess. Ein serverseitiger Runner bräuchte Kopien von all dem. Der Feature-Index sagt es ohne Umschweife: Graphs lesen geht im Browser, ausführen braucht den Desktop.
Wie erreicht eine Anfrage einen Desktop, zu dem niemand eine Verbindung aufbauen kann?
Gar nicht. Der Desktop fragt nach Arbeit. apps/web/src/hooks/useConnectedGraphRelay.ts, einmal im Desktop-Hauptfenster gemountet, macht Folgendes:
- Alle 20 Sekunden (und direkt nach Änderungen an der Freigabe) überspringt es den Durchlauf, wenn der vorige noch beschäftigt ist.
- Es sendet Completions aus einer lokalen Outbox erneut, die beim letzten Mal nicht beim Server angekommen sind.
- Für jedes Projekt, das der Besitzer aktiviert hat, schickt es
POST /external-assistants/graph-relays/syncmit Metadaten der für dieses Projekt angehakten Sessions, höchstens 100: Name, Beschreibung, Slug, Version, Status. Die Antwort enthält die für dieses Projekt eingereihten Commands. - Für jedes Command prüft es erneut, ob das Projekt noch aktiviert, die Session noch angehakt ist und noch denselben Slug hat. Falls nicht, schließt es das Command als
cancelledab, mit "Graph session is no longer shared with this project". - Andernfalls führt es das Command über
run_graph_nowauf dem Channelgraph:external:<command id>aus, mit einem 3-Sekunden-Control-Poll für Abbrüche, und postet danach das begrenzte Ergebnis.
list_graph_sessions zeigt also, was der letzte Sync hochgeladen hat, und run_graph_session schreibt eine Zeile, die der Desktop beim nächsten Durchlauf findet. Die Command-IDs sind UUIDs. ConnectedGraphProjectBinding lehnt alles andere ab, bevor es die Channel-zu-Projekt-Zeile schreibt, die den Run dem verbundenen Projekt zuordnet.
Der Nachbau
Auf diesem Mac läuft kein Claude-Binary, also spielt sim.py alle drei Rollen auf einer simulierten Uhr. Die Desktop-Hälfte ist ein Port des Relays oben: 20 s Poll, Überspringen bei Beschäftigung, serielle Commands, erneute Prüfung vor dem Ausführen, 3 s Control-Poll, Outbox, die Ergebnisgrenze von 100,000 Zeichen. Die Server-Hälfte ist ein Ersatz, weil ihr Code nicht öffentlich ist. Sie implementiert, was die Tool-Beschreibungen sagen: eine Queue, Ergebnisse, die nur der Agent lesen kann, der den Run gestartet hat, unterschiedlichen Abbruch für eingereiht und aktiv. Außerdem implementiert sie das Idempotenz-Verhalten, das der CLI-Guide von osFoundry verlangt ("reuse idempotency keys after a lost response"). Der Client ist ein geskripteter Claude.
Die Laufzeiten der Graphs sind synthetisch: lognormal, Median 60 s, mindestens 5 s. Jedes Experiment nutzt 500–2,000 geseedete Runs, und die Phase des Relays ist relativ zur Anfrage zufällig. Python 3.9.6 auf einem Apple M1, 2.8 s Wall-Time für alles. Die Ausgabe liegt in code/run.out.
Wie lange wartet ein Run in der Queue?
pickup wait (queued->started), n=2000: mean 9.8 s, median 9.8 s, p95 18.9 s, max 20.0 s Bis zu einem Poll-Intervall, im Schnitt ein halbes. Das ist der Preis für einen Desktop, der nur ausgehende Verbindungen aufbaut. Er hält keinen offenen Port und keinen langlebigen Socket, und ein zugeklappter Laptop ist einfach ein Relay, das noch nicht gepollt hat.
Der zweite Kostenpunkt ist weniger offensichtlich. Das Relay führt Commands nacheinander aus und überspringt seinen Timer, solange es beschäftigt ist. Fünf gleichzeitig eingereihte Runs werden der Reihe nach fertig:
| Gleichzeitig eingereihte Runs | Erstes Ergebnis (Median) | Letztes Ergebnis (Median) | Letztes Ergebnis (p95) |
|---|---|---|---|
| 1 | 74 s | 74 s | 254 s |
| 3 | 73 s | 233 s | 483 s |
| 5 | 75 s | 399 s | 776 s |
Ein Agent, der fünf Graph-Runs auffächert, um Zeit zu sparen, bekommt den langsamsten möglichen Ablauf. Ein Desktop ist ein Worker. Wenn du Parallelität willst, gehört sie in den Graph, wo die Engine dafür max_concurrency und eine Token-Obergrenze hat.
Wie oft sollte der Agent get_graph_run aufrufen?
Einen Push-Kanal zurück zu Claude gibt es auch nicht. Jede Strategie tauscht Aufrufe gegen die Verzögerung, mit der der Agent das Ergebnis bemerkt. 2,000 Runs pro Zeile:
| Poll-Strategie des Clients | Aufrufe pro Run (Mittel) | p95 | Ergebnis verspätet bemerkt (Mittel) | p95 |
|---|---|---|---|---|
| alle 2 s | 47.1 | 118 | 1.0 s | 1.9 s |
| alle 5 s | 19.1 | 48 | 2.5 s | 4.8 s |
| alle 15 s | 6.7 | 16 | 7.5 s | 14.2 s |
| alle 30 s | 3.6 | 8 | 14.9 s | 28.4 s |
| Backoff 2, 4, 8 … 30 s | 6.6 | 11 | 14.8 s | 28.4 s |
| 20 s warten, dann alle 10 s | 8.8 | 23 | 5.0 s | 9.6 s |
Alle 2 s zu pollen kostet 47 Tool-Aufrufe, also 47 Durchläufe durch den Kontext des Modells, um ein paar Sekunden bei einem Job zu sparen, der eine Minute dauert. Exponentielles Backoff wirkt clever und verliert hier: Es macht so viele Aufrufe wie ein fester 15-s-Poll und bemerkt das Ergebnis doppelt so spät, weil es schon an seiner 30-s-Grenze ist, wenn ein typischer Graph fertig wird. Die letzte Zeile nutzt, was dir das Relay verrät. Im ersten Poll-Intervall kann ohnehin nichts starten, also kostet es fast nichts, vor der ersten Prüfung etwa ein Intervall zu warten.
Was stoppt ein Abbruch eigentlich?
cancel at a random 0-40 s after queueing, n=2000: still queued 523, already running 1370 queued : terminal after 0.0 s (graph never started) running: terminal after mean 2.0 s, p95 3.3 s, max 3.5 s Ein Abbruch in der Queue ist eine Zustandsänderung auf dem Server. Der Desktop sieht das Command nie, und es werden keine Tokens verbraucht. Ein Abbruch während der Ausführung ist ein Flag, das der Desktop bei seinem 3-Sekunden-Control-Poll liest. Dann ruft er fleet_cancel_run mit der Run-ID auf, die er aus dem fleet_started-Event bekommen hat. Das Relay schickt diesen Abbruch einmal und behält das Ergebnis als cancelled, selbst wenn der Graph es zwischen Flag und Stopp noch schafft, fertig zu werden.
Was bringt der Idempotency Key?
idempotency_key ist Pflicht, 8–128 Zeichen. 200 Runs pro Wiederholung, 30% der Antworten gehen verloren, damit der Client es erneut versucht:
| Retry-Verhalten | Gesendete Aufrufe | Eingereihte Graph-Runs |
|---|---|---|
| neuer Key beim Retry | 266 / 302 / 286 | 266 / 302 / 286 |
| gleicher Key beim Retry | 270 / 291 / 275 | 200 / 200 / 200 |
Bei einem Graph, der E-Mails entwirft oder Dateien schreibt, ist ein Duplikat keine verschwendete Minute, sondern eine zweite Runde Seiteneffekte. Deshalb ist der Key im Schema Pflicht und das Tool mit destructiveHint: true markiert.
Was passiert, wenn der Desktop wegfällt?
desktop offline 600 s: statuses ['queued'], executed 0 back online: first start +3 s, all 5 ['completed'] by +131 s, executed 5 completion POST lost once, outbox=on : status completed, delivered 40 s after start completion POST lost once, outbox=off: status running, never delivered Eingereihte Arbeit überlebt einen Desktop, der offline ist, weil die Queue auf dem Server liegt. Die Schwachstelle ist der Rückweg. Hat der Desktop einen Graph beendet und ging sein POST verloren, würde ein Relay, das das Ergebnis nur im Speicher hält, den Run für immer auf running hängen lassen. Das echte Relay schreibt jede Completion vor dem Posten in localStorage und entfernt sie erst nach einem 2xx, sodass der nächste Durchlauf 20 s später sie zustellt.
Wie groß darf das Ergebnis sein?
Das Relay postet {execution_id, status, result, total_tokens, reported_cost}, wenn das JSON.stringify davon höchstens 100,000 Zeichen lang ist. Andernfalls postet es {execution_id, status, result_truncated: true}. Fehler werden auf 2,000 Zeichen gekürzt. Die Grenze zählt Zeichen, nicht Bytes:
result text that still fits (ascii): 99,899 chars = 99,899 UTF-8 bytes result text that still fits (japanese): 99,899 chars = 299,697 UTF-8 bytes Der Schnitt ist alles oder nichts. Es gibt kein halbes Ergebnis, das man falsch lesen könnte, und der vollständige Run-Datensatz bleibt im Verlauf des Desktops unter dieser execution_id. So oder so ist das viel Text, den man einem Modell vorsetzt. Ein Graph, der von einem Agenten aufgerufen werden soll, sollte deshalb mit einer Zusammenfassung enden, nicht mit einem Dump.
Wer kann den Run noch sehen?
another agent reads the run: {'error': 'Graph run not found or access removed'} Im Ersatz bekommt eine zweite Verbindung, die nach Claudes command_id fragt, denselben Fehler, den der Live-Server für eine ausgedachte ID zurückgegeben hat. get_graph_run und cancel_graph_run sind beide so beschrieben, dass sie auf Runs arbeiten, die "started by this Connected agent" sind. Wenn "gehört dir nicht" und "existiert nicht" nicht unterscheidbar sind, bestätigt eine geleakte ID nichts.
Wenn du so etwas selbst baust
- Lass die Maschine, der die Tools gehören, die Arbeit abholen. Ein ausgehender Poll übersteht NAT, Ruhezustand und zugeklappte Laptops. Wähle das Intervall in dem Wissen, dass jeder Aufrufer im Schnitt die Hälfte davon wartet.
- Gib vom Start-Aufruf ein Ticket zurück und schreib in die Tool-Beschreibung, welches Tool das Ergebnis liest. Aus dem Namen kann das Modell keine Queue ableiten.
- Setz die erste Statusprüfung etwa ein Abhol-Intervall nach hinten. Fester Poll schlägt exponentielles Backoff, wenn sich die Joblaufzeiten um eine Minute herum sammeln.
- Mach einen Idempotency Key im Start-Aufruf zur Pflicht. Bei Jobs mit Seiteneffekten ist eine wiederholte Anfrage ohne Key ein zweiter Run.
- Persistiere Completions auf dem Worker, bevor du sie sendest. Das Ergebnis ist der Teil des Roundtrips, den du nicht billig neu berechnen kannst.
- Prüf die Zustimmung auf dem Worker direkt vor dem Ausführen erneut, nicht nur beim Einreihen des Jobs. Die Freigabe kann sich dazwischen ändern.
Die Kurzdefinition des darunterliegenden Protokolls von osFoundry steht im Glossareintrag zu MCP, und das größere Muster, zu dem Graphs gehören, im Eintrag zu Agent-Orchestrierung.