#Scoping question

1 messages · Page 1 of 1 (latest)

wet flare
#

Scoping question before I build something that may already exist.
I want to automate async communication between three agents I run: Retta (OpenClaw gateway on atmt VPS), a Claude Cowork session on my Windows, and a Claude Code instance running in tmux on a second VPS. Today I move files between them manually via SCP. Goal is to drop the manual step.
Before I stand up Gitea as a git-relay transport, I want to check whether OpenClaw's recent device-pairing / pairing-approvals feature already covers this.

Specifically:
Can non-gateway clients (a Cowork session, a Claude Code CLI instance) register as paired peers on an OpenClaw gateway, or is pairing scoped to gateway-to-gateway / gateway-to-browser only?
If peer pairing exists, is there any agent-to-agent send primitive exposed today, message passing, file sync, session-to-session signal, anything that would let one paired client push state to another?
If not today, is this on the roadmap, and if so at roughly what horizon?

If native support exists or is close, I'll skip the Gitea build and wait. If not, no problem, I'll fall back to self-hosted git and move on.

brave irisBOT
#

To help others find answers, you can mark your question as solved via Right click solution message -> Apps -> âś… Mark Solution

long questBOT
worthy finchBOT
#

Short version: pairing will not replace your Gitea relay today.

  1. Can Cowork / Claude Code register as paired peers?
    Not natively. Pairing is broader than browser-only, it is a Gateway ↔ OpenClaw WS client trust step for operator and node roles, not just gateway-to-browser. But the client has to speak the OpenClaw protocol. So a custom client could pair, but Claude Cowork / Claude Code are not drop-in paired peers today.
    Docs: https://docs.openclaw.ai/gateway/protocol https://docs.openclaw.ai/channels/pairing

  2. Is there any agent-to-agent send primitive?
    Inside OpenClaw sessions, yes. There is sessions.send / sessions_send, plus the A2A follow-up path when session visibility and cross-agent allowlisting are enabled.
    But pairing itself is not a peer bus. There is no first-class paired-client message relay, file sync, or state sync between arbitrary external peers today.
    Docs: https://docs.openclaw.ai/concepts/session-tool https://docs.openclaw.ai/tools/acp-agents

  3. Roadmap / horizon?
    I do not see a published ETA for “arbitrary paired peers can push state/files to each other.” The docs hint at future clients, but not a near-term peer-sync transport. So I would not wait on native support for this use case.

Best current options

  • For reliable file/state handoff: your git/Gitea relay is still the sane move.
#

If you want, I can sketch the lightest-weight architecture for either:

  • Gitea as async artifact/state relay, or
  • OpenClaw ACP/MCP bridge for session-level coordination.
wet flare
#

Thanks, that clarifies a lot.
Send both sketches. File-relay and session-coordination look complementary to me, not competing: Gitea for durable vault artifacts (core7 .md files, session-state logs, breadcrumbs), ACP/MCP for live cross-agent signalling without a file round-trip.
Bonus question while you're sketching: for the MCP bridge, is there anything blocking Cowork from being a second openclaw mcp serve client alongside Claude Code? I'd want both ClawCo (Cowork) and VSClaw (Claude Code on tmux) connected to the same Gateway so they can both messages_send / events_wait through Lobretta's channel routes. If yes, no special wiring needed, just note that in the sketch. If it's Claude-Code-only today, that's a useful constraint to know upfront.

worthy finchBOT
#

Yep, complementary is the right mental model.

Bonus first: nothing in OpenClaw MCP looks Claude-Code-only.
If Cowork can act as a normal stdio MCP client, it should be able to run its own openclaw mcp serve process alongside Claude Code.

So the likely answer is:

  • Claude Code: yes
  • Cowork: also yes, if it supports generic MCP client wiring
  • OpenClaw is not the blocker, Cowork’s MCP support is

Important caveats:

  • each MCP client gets its own bridge process
  • each bridge has its own live in-memory event queue
  • events_wait is not shared and not durable
  • for non-Claude clients, I would set --claude-channel-mode off because auto currently behaves like on
#

Docs: https://docs.openclaw.ai/cli/mcp

Sketch 1, durable file relay

Recommendation: one Gitea repo, one writer branch per agent.

Example:

  • retta
  • clawco
  • vsclaw
  • optional shared

Suggested layout on each writer branch:

#
handoffs/<recipient>/<timestamp>-<id>.md
artifacts/<topic>/<artifact-id>.md
logs/<yyyy>/<mm>/<dd>/<session>.jsonl
receipts/<sender>/<handoff-id>.json
breadcrumbs/<run-id>.json

Protocol

  1. Agent writes artifacts to its own branch only
  2. Agent writes a small handoff file that points at those artifacts
  3. Agent commits, pushes
  4. Agent emits a live signal with {branch, commit, handoff_path}
  5. Receiver fetches that branch/commit, reads the handoff, processes it
  6. Receiver writes an ack/receipt on its own branch
#

Why this shape

  • no multi-writer branch fights
  • durable and restart-safe
  • easy audit trail
  • humans can inspect it
  • live signalling can fail without losing the artifact

Handoff file shape

id: handoff-2026-04-23T08:00:00Z-001
from: retta
to: clawco
kind: artifact-ready
commit: abc1234
#
artifacts:
  - artifacts/core7/core7-0423.md
  - logs/2026/04/23/retta-session.jsonl
parent: handoff-previous-id

I would make these append-only. No rewriting old handoffs.

Sketch 2, live session coordination

Use OpenClaw as the control plane, not the vault.

Topology

  • Retta / Lobretta = Gateway owner
  • ClawCo (Cowork) = openclaw mcp serve
#
  • VSClaw (Claude Code in tmux) = openclaw mcp serve

Both external clients can use:

  • conversations_list
  • messages_read
  • messages_send
  • events_wait

Docs: https://docs.openclaw.ai/cli/mcp

What to send live

Only small control messages, for example:

  • artifact-ready
  • need-context
#
  • claimed
  • done
  • retry
  • heartbeat

Example payload idea:

{
  "type": "artifact-ready",
  "from": "vsclaw",
  "branch": "vsclaw",
  "commit": "abc1234",
  "handoff": "handoffs/clawco/2026-04-23T08-00-00Z-001.md"
}
#

Rule of thumb

  • Git/Gitea = durable truth
  • MCP = live nudges and wakeups

Do not put irreplaceable state only in MCP events.

Constraints to plan around

  1. MCP bridge is route-backed
    messages_send only works for conversations that already exist in Gateway session state with stored route metadata.

  2. Per-client live queue
    If Cowork and Claude Code both connect, each gets its own live queue starting when its bridge starts.

  3. No durable replay from events_wait
    For backlog, read transcript/history, not the live queue.

#
  1. Concurrency is yours to manage
    If both clients act on the same conversation, use idempotent messages and include artifact refs.

Practical v1 I’d build

  • Gitea repo with one branch per agent
  • MCP bridge on both Cowork and Claude Code
  • every live signal includes {branch, commit, path}
  • receivers always confirm by reading the durable artifact from git
  • optional receipts/ files for exactly-once-ish coordination

So, net, I’d say:

  • Yes, Cowork can probably be a second openclaw mcp serve client
  • No, I would still build the Gitea relay
  • the two layers fit together cleanly
#

If you want, I can do one more pass and turn this into a concrete v1 folder schema + message schema you can implement directly.

wet flare
#

Accepting both sketches. Branch-per-writer is clean, the git/MCP separation is exactly right.
Yes, please turn this into concrete v1 schemas: folder layout and message schema. I'll implement directly from those.
One clarification before you sketch: how is the initial conversation route bootstrapped for pure agent-to-agent signals? The MCP messages_send constraint says a conversation must already exist in Gateway session state with stored route metadata. For the three-agent control plane, I'd rather not route those signals through a real Discord/Telegram channel (clutter, and mixes agent control traffic with human chat). Is there an OpenClaw-internal pseudo-channel or a way to create a bare agent-to-agent session that messages_send will accept? Or is the intended pattern genuinely "set up a dedicated agent-comms chat surface"?
Also still open from my earlier question, in case it got buried: on WebSocket disconnect (tailnet flap, Gateway restart), does openclaw mcp serve reconnect its upstream WS internally and keep the stdio session alive, or does it exit and leave Claude Code's server marked failed? Determines whether we need a supervisor on each client host.

worthy finchBOT
#

Yep, two important clarifications first.

1) Pure agent-to-agent control traffic via MCP

Not really. openclaw mcp serve is route-backed. It exposes conversations that already have Gateway session route metadata, and its messages_send path goes through Gateway send, not arbitrary internal session injection.

So for your question:

  • Is there an OpenClaw-internal pseudo-channel for MCP?
    Not as a first-class MCP conversation surface today.

  • Can MCP create a bare internal agent-to-agent session and use messages_send on it?
    No, not the way the bridge is shaped now.

  • So is the intended MCP pattern basically “use a real chat surface”?
    Yes, for MCP specifically.

#

If you want a no-clutter internal control plane, I would use:

  • ACP to target named Gateway session keys, or
  • a thin custom Gateway WS client using sessions.create / sessions.send

Docs: https://docs.openclaw.ai/cli/mcp https://docs.openclaw.ai/cli/acp https://docs.openclaw.ai/gateway/protocol

So my recommendation is:

  • MCP for route-backed human/channel conversations
  • Git for durable artifacts
  • ACP or direct WS for pure internal control sessions

2) Does openclaw mcp serve survive upstream WS disconnects?

For tailnet flaps / gateway restarts after the bridge is already up, it looks like yes:

#
  • the underlying GatewayClient has reconnect/backoff logic
  • the MCP stdio server does not shut down on ordinary upstream WS close
  • it should keep the stdio session alive and reconnect upstream

But there is one important edge:

  • if the bridge never reaches ready on startup, it can fail out and exit
  • auth/pairing/config problems can still leave you with a dead process

So my practical answer is:

  • transient restart/flap: should reconnect internally
  • initial bring-up / auth trouble: can still fail hard
  • recommendation: I would still run a lightweight supervisor on each client host

#

Concrete v1

I’d make Git the source of truth, and use a small shared envelope everywhere.

Branches

One branch per writer:

  • retta
  • clawco
  • vsclaw

Optional later:

  • shared

Folder layout on each writer branch

#
v1/
  handoffs/
    retta/
    clawco/
    vsclaw/

  artifacts/
    core7/
    session-state/
    breadcrumbs/
    logs/

  runs/
    <runId>/
      state.json
#
      events.jsonl

  receipts/
    retta/
    clawco/
    vsclaw/

Rules

  • each agent writes only its own branch
  • handoffs/<recipient>/ is the sender’s outbox to that recipient
  • artifacts are immutable, new file for every version
  • receipts are written by the receiver on its own branch
  • live signals only point at git refs, they are never the source of truth
#

File naming

Use sortable UTC timestamps plus stable ids.

Handoff file

v1/handoffs/clawco/2026-04-23T08-30-00Z--hf_01JSC...json

Artifact file

v1/artifacts/core7/2026-04-23T08-29-55Z--art_01JSC...md
#

Receipt file

v1/receipts/retta/2026-04-23T08-31-10Z--hf_01JSC...--processed.json

Schema 1, durable handoff file

Store this in git.

{
  "schema": "lobretta.v1.handoff",
  "handoffId": "hf_01JSC8Y8Y7YV0Y7M9JQ1K2R3T4",
#
  "from": "retta",
  "to": "clawco",
  "kind": "artifact_ready",
  "createdAt": "2026-04-23T08:30:00Z",
  "runId": "run_01JSC8WZ...",
  "replyTo": null,
  "subject": "core7 update for review",
  "summary": "New core7 snapshot plus session-state log.",
  "git": {
    "branch": "retta",
    "commit": "abc1234def5678",
    "path": "v1/handoffs/clawco/2026-04-23T08-30-00Z--hf_01JSC8Y8Y7YV0Y7M9JQ1K2R3T4.json"
  },
  "artifacts": [
    {
#
      "artifactId": "art_01JSC8XV...",
      "kind": "core7",
      "path": "v1/artifacts/core7/2026-04-23T08-29-55Z--art_01JSC8XV....md",
      "contentType": "text/markdown",
      "sha256": "..."
    },
    {
      "artifactId": "art_01JSC8XW...",
      "kind": "session_state",
      "path": "v1/runs/run_01JSC8WZ.../state.json",
      "contentType": "application/json",
      "sha256": "..."
    }
  ],
  "payload": {
#
    "priority": "normal",
    "tags": ["core7", "handoff"],
    "note": "Please ingest and emit receipt after parse."
  }
}

Allowed kind values for v1

I would start with just these:

  • artifact_ready
  • context_request
  • context_reply
  • claim
  • result
#
  • error

Keep it boring.


Schema 2, artifact metadata

You can inline this in handoff.artifacts, but I’d keep the shape stable:

{
  "artifactId": "art_01JSC8XV...",
  "kind": "core7",
  "path": "v1/artifacts/core7/2026-04-23T08-29-55Z--art_01JSC8XV....md",
  "contentType": "text/markdown",
#
  "sha256": "...",
  "bytes": 4821
}

Allowed artifact kind values for v1

  • core7
  • session_state
  • session_log
  • breadcrumb
  • result_note
  • attachment_manifest

#

Schema 3, live signal

Send this over MCP, ACP, or direct WS as the wake-up message.

This is intentionally tiny.

{
  "schema": "lobretta.v1.signal",
  "type": "handoff_created",
  "handoffId": "hf_01JSC8Y8Y7YV0Y7M9JQ1K2R3T4",
  "from": "retta",
  "to": "clawco",
  "createdAt": "2026-04-23T08:30:01Z",
  "git": {
    "branch": "retta",
#
    "commit": "abc1234def5678",
    "path": "v1/handoffs/clawco/2026-04-23T08-30-00Z--hf_01JSC8Y8Y7YV0Y7M9JQ1K2R3T4.json"
  }
}

Allowed type values for v1

  • handoff_created
  • receipt_created
  • heartbeat
  • need_attention

Rule: the receiver should always fetch git and read the handoff file, never trust the signal alone.


#

Schema 4, receipt

Receiver writes this on its own branch.

{
  "schema": "lobretta.v1.receipt",
  "receiptId": "rcpt_01JSC92N...",
  "handoffId": "hf_01JSC8Y8Y7YV0Y7M9JQ1K2R3T4",
  "from": "clawco",
  "to": "retta",
  "status": "processed",
  "createdAt": "2026-04-23T08:31:10Z",
  "git": {
    "branch": "clawco",
    "commit": "fedcba9876543210",
#
    "path": "v1/receipts/retta/2026-04-23T08-31-10Z--hf_01JSC8Y8Y7YV0Y7M9JQ1K2R3T4--processed.json"
  },
  "note": "Parsed successfully and imported core7."
}

Allowed status values for v1

  • seen
  • claimed
  • processed
  • rejected
  • failed

#

Recommended v1 flow

Durable path

  1. sender writes artifacts
  2. sender writes handoff JSON
  3. sender commits and pushes
  4. sender emits tiny live signal
  5. receiver fetches sender branch at git.commit
  6. receiver reads handoff JSON
  7. receiver processes artifacts
  8. receiver writes receipt on its own branch
  9. receiver optionally emits receipt_created

Idempotency rule

Treat handoffId as the idempotency key.

#

If a signal is repeated, receiver checks:

  • do I already have a receipt for this handoffId?
  • did I already process this exact sender branch + commit + path?

If yes, no-op.


Control plane recommendation

Given your “no real chat clutter” requirement, my clean v1 would be:

  • Git repo for artifacts and receipts
  • ACP or direct WS for internal control sessions
  • MCP only where you actually want route-backed human-channel access
#

If you want a concrete naming scheme for the internal control sessions too, I’d use:

  • session:control:retta
  • session:control:clawco
  • session:control:vsclaw

and keep the live signal payload above unchanged.

If you want, I can do one final pass and give you a copy-pasteable v1 spec document with:

  • repo layout
  • JSON schemas
  • invariants
  • happy path
  • failure/retry behavior
wet flare
#

Yes, please do the final pass. Copy-pasteable v1 spec doc is exactly what I need.
Two additions while you're at it:

Concrete supervisor pattern per host. You flagged "lightweight supervisor on each client host" for the stdio bridge. What shape are you recommending? systemd user unit, tmux wrapper with restart-on-exit, shell loop, something else? The three client hosts have different init stories (atmt systemd, homelab systemd, Windows workstation running Cowork under no init system at all), so if you're shape-agnostic just name the minimum contract (restart within N seconds, preserve stdio handle, log destination) and I'll pick per-host.
For the internal control plane, give me one minimal working example. ACP versus direct WS is still an open pick. My lean is direct WS over ACP: one less abstraction layer, and the Gateway protocol doc presumably has enough for a thin client. If you agree, a short pseudocode skeleton that opens the WS, authenticates, joins session:control:<self>, sends a signal, receives one, and handles clean shutdown would anchor the build. If you think ACP is the better fit here, take that path and say why.

Once I have the spec doc plus those two, I can start implementation.

worthy finchBOT
#

I agree with your lean: direct WS is the better fit for the internal control plane.

Why:

  • your control traffic is session-oriented, not prompt-oriented
  • you want internal named sessions with no chat clutter
  • the Gateway already exposes the right primitives: sessions.create, sessions.send, sessions.messages.subscribe
  • ACP would work, but it is one more layer than you need for “send small signal, receive small signal”

Also, for the stdio bridge supervisor question, the key nuance is:

  • for openclaw mcp serve, the best supervisor is a wrapper process that the MCP client launches
  • not systemd, because the MCP client owns stdio
  • for the direct WS control-plane daemon, use normal host-native supervision

Here’s the copy-pasteable v1 spec doc.

#
# Lobretta Multi-Agent Relay v1

Status: proposed v1
Scope: three-agent async coordination
Agents:
- retta
- clawco
- vsclaw

## 1. Architecture

This system has three separate planes:

1. Durable artifact plane
   - Transport: Git/Gitea
#
   - Purpose: durable handoffs, logs, state snapshots, breadcrumbs
   - Truth source: git commit + file path

2. Route-backed chat plane
   - Transport: OpenClaw MCP bridge (`openclaw mcp serve`)
   - Purpose: human-facing channel conversations that already have Gateway route metadata
   - Not used for pure internal control traffic

3. Internal control plane
   - Transport: direct Gateway WebSocket
   - Purpose: pure agent-to-agent signalling with no Discord/Telegram clutter
   - Session keys:
     - `session:control:retta`
     - `session:control:clawco`
     - `session:control:vsclaw`
#

## 2. Branch ownership

One writer branch per agent:

- `retta`
- `clawco`
- `vsclaw`

Rules:

- each agent writes only its own branch
- agents may read any branch
- no shared write branch in v1
- optional `shared` branch can be added later for promoted outputs
#

## 3. Repo layout

Each writer branch uses the same layout:

```text
v1/
  handoffs/
    retta/
    clawco/
    vsclaw/

  artifacts/
    core7/
    session-state/
    breadcrumbs/
#

logs/

runs/
<runId>/
state.json
events.jsonl

receipts/
retta/
clawco/
vsclaw/


### Path semantics

- `v1/handoffs/<recipient>/...`
#
  - sender outbox for that recipient
  - immutable handoff envelope files

- `v1/artifacts/core7/...`
  - durable markdown artifacts

- `v1/artifacts/session-state/...`
  - durable machine-readable snapshots

- `v1/artifacts/breadcrumbs/...`
  - small structured trace points

- `v1/artifacts/logs/...`
  - append-only or chunked logs
#
- `v1/runs/<runId>/state.json`
  - latest structured state for one run

- `v1/runs/<runId>/events.jsonl`
  - append-only event stream for one run

- `v1/receipts/<counterparty>/...`
  - receiver-written acknowledgements on receiver branch

## 4. Naming convention

Use sortable UTC timestamps plus opaque ids.

Examples:
#
- handoff:
  - `v1/handoffs/clawco/2026-04-23T08-30-00Z--hf_01JSC8....json`

- artifact:
  - `v1/artifacts/core7/2026-04-23T08-29-55Z--art_01JSC8....md`

- receipt:
  - `v1/receipts/retta/2026-04-23T08-31-10Z--hf_01JSC8....--processed.json`

## 5. Canonical object shapes

These are the normative v1 payload shapes.

### 5.1 Handoff
#
Stored in git under `v1/handoffs/<recipient>/...json`

```json
{
  "schema": "lobretta.v1.handoff",
  "handoffId": "hf_01JSC8Y8Y7YV0Y7M9JQ1K2R3T4",
  "from": "retta",
  "to": "clawco",
  "kind": "artifact_ready",
  "createdAt": "2026-04-23T08:30:00Z",
  "runId": "run_01JSC8WZ...",
  "replyTo": null,
  "subject": "core7 update for review",
  "summary": "New core7 snapshot plus session-state log.",
  "git": {
    "branch": "retta",
#

"commit": "abc1234def5678",
"path": "v1/handoffs/clawco/2026-04-23T08-30-00Z--hf_01JSC8Y8Y7YV0Y7M9JQ1K2R3T4.json"
},
"artifacts": [
{
"artifactId": "art_01JSC8XV...",
"kind": "core7",
"path": "v1/artifacts/core7/2026-04-23T08-29-55Z--art_01JSC8XV....md",
"contentType": "text/markdown",
"sha256": "...",
"bytes": 4821
},
{
"artifactId": "art_01JSC8XW...",
"kind": "session_state",
"path": "v1/runs/run_01JSC8WZ.../state.json",
"contentType": "application/json",

#

"sha256": "...",
"bytes": 912
}
],
"payload": {
"priority": "normal",
"tags": ["core7", "handoff"],
"note": "Please ingest and emit receipt after parse."
}
}


#### Allowed `kind` values in v1

- `artifact_ready`
- `context_request`
#
- `context_reply`
- `claim`
- `result`
- `error`

### 5.2 Artifact reference

Usually embedded in `handoff.artifacts`.

```json
{
  "artifactId": "art_01JSC8XV...",
  "kind": "core7",
  "path": "v1/artifacts/core7/2026-04-23T08-29-55Z--art_01JSC8XV....md",
  "contentType": "text/markdown",
  "sha256": "...",
#

"bytes": 4821
}


#### Allowed artifact `kind` values in v1

- `core7`
- `session_state`
- `session_log`
- `breadcrumb`
- `result_note`
- `attachment_manifest`

### 5.3 Live signal

Sent over direct WS control sessions.
#
Not a source of truth.
Always points to a git-backed handoff or receipt.

```json
{
  "schema": "lobretta.v1.signal",
  "type": "handoff_created",
  "handoffId": "hf_01JSC8Y8Y7YV0Y7M9JQ1K2R3T4",
  "from": "retta",
  "to": "clawco",
  "createdAt": "2026-04-23T08:30:01Z",
  "git": {
    "branch": "retta",
    "commit": "abc1234def5678",
    "path": "v1/handoffs/clawco/2026-04-23T08-30-00Z--hf_01JSC8Y8Y7YV0Y7M9JQ1K2R3T4.json"
  }
#

}


#### Allowed `type` values in v1

- `handoff_created`
- `receipt_created`
- `heartbeat`
- `need_attention`

### 5.4 Receipt

Stored in git under `v1/receipts/<counterparty>/...json`

```json
{
  "schema": "lobretta.v1.receipt",
#

"receiptId": "rcpt_01JSC92N...",
"handoffId": "hf_01JSC8Y8Y7YV0Y7M9JQ1K2R3T4",
"from": "clawco",
"to": "retta",
"status": "processed",
"createdAt": "2026-04-23T08:31:10Z",
"git": {
"branch": "clawco",
"commit": "fedcba9876543210",
"path": "v1/receipts/retta/2026-04-23T08-31-10Z--hf_01JSC8Y8Y7YV0Y7M9JQ1K2R3T4--processed.json"
},
"note": "Parsed successfully and imported core7."
}


#### Allowed receipt `status` values in v1
#

- `seen`
- `claimed`
- `processed`
- `rejected`
- `failed`

## 6. Invariants

These rules are mandatory.

1. Git is the source of truth.
   - A live signal is never authoritative by itself.

2. Every live signal must include:
#
   - `handoffId`
   - sender branch
   - sender commit
   - handoff path

3. Receivers must verify the handoff from git before acting.

4. All artifact files are immutable.
   - New version means new file.

5. `handoffId` is the idempotency key.

6. Each control session is owned by exactly one agent:
   - `session:control:retta`
   - `session:control:clawco`
#
   - `session:control:vsclaw`

## 7. Happy-path flow

### Sender

1. write artifacts on sender branch
2. write handoff envelope under `v1/handoffs/<recipient>/...json`
3. commit and push
4. send live `handoff_created` signal to recipient control session

### Receiver

1. receive signal on own control session
2. fetch sender branch at signalled commit
#
3. read handoff file from signalled path
4. verify schema and artifact refs
5. process artifacts
6. write receipt on own branch
7. optionally send live `receipt_created` signal back

## 8. Retry / idempotency behavior

On receiving a signal:

1. if `handoffId` already has a terminal receipt (`processed`, `rejected`, `failed`), no-op
2. if exact sender tuple already processed:
   - `(git.branch, git.commit, git.path)`
   - no-op
3. otherwise process normally
#

Recommended receiver local cache key:
- `handoffId`
- plus exact git tuple for duplicate suppression

## 9. Supervisor contract

Two different supervision modes exist.

### 9.1 MCP stdio bridge supervision

Applies to:
- `openclaw mcp serve`

Important:
#
- do not supervise this with systemd as a detached service
- the MCP client owns stdio
- the supervisor must be the command the MCP client launches

Recommended shape:
- a small wrapper process
- MCP client launches the wrapper
- wrapper runs `openclaw mcp serve`
- if child exits unexpectedly and parent stdio is still open, wrapper restarts it
- wrapper keeps the same stdin/stdout handles for the parent MCP client

Minimum contract:

- restart after unexpected exit within 2 seconds
- exponential backoff allowed, cap at 30 seconds
#
- preserve wrapper stdin/stdout for lifetime of parent session
- log child stderr to a file or stderr sidecar
- stop cleanly when parent stdin closes
- surface repeated crash loops in logs

### 9.2 Direct WS control daemon supervision

Applies to:
- internal control-plane client

Recommended shapes:
- Linux host with systemd: systemd user unit or system service
- Windows workstation: Task Scheduler, NSSM, or equivalent
- tmux-only host: tmux session with restart wrapper is acceptable in v1
#
Minimum contract:

- restart within 2 seconds after unexpected exit
- exponential backoff allowed, cap at 30 seconds
- stdout/stderr to durable host log
- SIGTERM / equivalent should trigger clean unsubscribe + socket close
- process should re-establish control-session subscription automatically on restart

## 10. Platform guidance

### Linux hosts
- direct WS daemon: use systemd
- MCP bridge: use wrapper command as MCP server command

### Windows workstation
#
- direct WS daemon: Task Scheduler or NSSM
- MCP bridge: PowerShell wrapper as MCP server command

### tmux-only environment
- direct WS daemon: tmux pane with restart wrapper is acceptable
- MCP bridge: still use wrapper command, not a detached service

## 11. Internal control plane

Transport:
- direct Gateway WebSocket
- operator role
- scopes:
  - `operator.read`
  - `operator.write`
#

Session keys:
- `session:control:retta`
- `session:control:clawco`
- `session:control:vsclaw`

Operations used:
- `sessions.create`
- `sessions.messages.subscribe`
- `sessions.messages.unsubscribe`
- `sessions.send`
- optionally `chat.history` for recovery

## 12. Direct WS client pseudocode
#
Language: TypeScript-like pseudocode

```ts
const SELF = "retta";
const OWN_KEY = `session:control:${SELF}`;

const ws = new WebSocket(GATEWAY_URL);
const pending = new Map<string, (msg: any) => void>();

let ownSessionSubscribed = false;
let connectNonce: string | null = null;

ws.onopen = () => {
  // wait for connect.challenge
};
#

ws.onmessage = async (raw) => {
const msg = JSON.parse(raw.data.toString());

if (msg.type === "event" && msg.event === "connect.challenge") {
connectNonce = msg.payload.nonce;

const device = loadOrCreateDeviceIdentity(); // persisted locally
const signed = signDevicePayload({
  deviceId: device.id,
  nonce: connectNonce,
  platform: PLATFORM,
  deviceFamily: DEVICE_FAMILY
});

ws.send(JSON.stringify({
  type: "req",
  id: uuid(),
#

method: "connect",
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: "lobretta-control",
version: "0.1.0",
platform: PLATFORM,
mode: "operator"
},
role: "operator",
scopes: ["operator.read", "operator.write"],
auth: { token: GATEWAY_TOKEN }, // or password
device: {
id: device.id,
publicKey: device.publicKey,
signature: signed.signature,

#

signedAt: signed.signedAt,
nonce: connectNonce
}
}
}));
return;
}

if (msg.type === "res" && msg.ok && msg.payload?.type === "hello-ok") {
await rpc("sessions.create", { key: OWN_KEY });
await rpc("sessions.messages.subscribe", { key: OWN_KEY });
ownSessionSubscribed = true;
return;
}

if (msg.type === "event" && msg.event === "session.message") {
const p = msg.payload;

#

if (p.sessionKey !== OWN_KEY) return;
if (p.message?.role !== "user") return;

const text = extractFirstTextBlock(p.message);
if (!text) return;

const signal = JSON.parse(text);
handleSignal(signal);
return;

}

if (msg.type === "res" && pending.has(msg.id)) {
pending.get(msg.id)!(msg);
pending.delete(msg.id);
return;
}
};

#

async function rpc(method: string, params: any) {
const id = uuid();
const frame = { type: "req", id, method, params };
const promise = new Promise((resolve, reject) => {
pending.set(id, (res) => {
if (res.ok) resolve(res.payload);
else reject(res.error);
});
});
ws.send(JSON.stringify(frame));
return await promise;
}

async function ensureControlSession(agent: string) {
const key = session:control:${agent};
await rpc("sessions.create", { key });
return key;

#

}

async function sendSignal(targetAgent: string, signal: any) {
const targetKey = await ensureControlSession(targetAgent);
await rpc("sessions.send", {
key: targetKey,
message: JSON.stringify(signal),
timeoutMs: 0,
idempotencyKey: uuid()
});
}

async function shutdown() {
if (ownSessionSubscribed) {
try {
await rpc("sessions.messages.unsubscribe", { key: OWN_KEY });
} catch {}

#

}
ws.close(1000, "shutdown");
}


## 13. Recovery behavior

If the direct WS daemon reconnects after network loss or gateway restart:

1. reconnect WebSocket
2. complete challenge/auth handshake again
3. call `sessions.create` for own control session again
4. call `sessions.messages.subscribe` again
5. resume receiving signals

For missed control messages:
#
- recover from git, not from the live socket
- optional recovery loop:
  - inspect counterparty branches for unreceipted handoffs addressed to self

## 14. MCP behavior

`openclaw mcp serve` is used only for route-backed conversations.

Notes:
- it is not the internal control plane
- after it has reached ready, transient gateway disconnects should reconnect internally
- fatal startup/auth failures can still exit the process
- wrapper supervision is still recommended for the stdio bridge

## 15. v1 non-goals
#

Not included in v1:

- shared multi-writer branch
- distributed locking
- exactly-once delivery
- binary blob storage beyond manifest/ref pattern
- MCP-only internal control plane
- custom OpenClaw plugin for relay semantics

## 16. Implementation checklist

- [ ] create Gitea repo
- [ ] create branches `retta`, `clawco`, `vsclaw`
- [ ] implement handoff envelope writer
#
- [ ] implement receipt writer
- [ ] implement direct WS control client
- [ ] create `session:control:<agent>` sessions on startup
- [ ] emit `handoff_created` after git push
- [ ] verify git before processing
- [ ] emit receipt after processing
- [ ] add MCP wrapper command for route-backed channel use
- [ ] add host-native supervision for direct WS control daemon

A couple of practical notes on top of the doc:

  • For MCP bridge supervision, I would concretely recommend:
    • Linux/macOS: wrapper shell script as the configured MCP server command
    • Windows: wrapper PowerShell script as the configured MCP server command
#
  • For the internal control plane, I think your “direct WS over ACP” instinct is right.

If you want, next I can turn just the direct WS pseudocode into a real minimal Node/TypeScript starter file.

wet flare
#

Spec accepted as-is. Thorough and internally consistent, I'll implement directly from it.
Yes, please do the Node/TypeScript starter file for the direct WS control daemon. Five things I'd want it to cover concretely:

Device identity persistence. loadOrCreateDeviceIdentity() wants a concrete shape: where the keypair lives on disk, file permissions, key algorithm (I assume ed25519), format of the signed nonce.
First-run pairing handshake. Does the starter file include the pairing flow, or does it assume a pre-paired device? If it includes pairing, walk through what happens on the Gateway side so I know what Lobretta will prompt me to approve on each remote host.
Secret loading. GATEWAY_TOKEN per host: recommended storage shape? Plain file with 600 perms is fine for Linux hosts; on Windows I'd want to know if you want Credential Manager / DPAPI or a protected file.
Clean restart path. After unexpected exit and relaunch, does the daemon reuse the existing device identity or re-pair? The answer shapes the supervisor contract per Section 9.2.
Daemon-to-agent glue. The daemon is a separate process from the LLM agent (Claude Code or Cowork). When a signal arrives, how does the daemon hand it to the agent inside its turn? File inbox, local MCP server on a unix socket, log tail, IPC? Pick whichever you think is cleanest for v1 and include it in the starter so I don't invent my own glue layer.

Once I have the starter file, I'll deploy Gitea first (Section 3 prerequisite), then wire the daemon on atmt as the proof-of-life host before rolling to homelab and Windows.

worthy finchBOT
#

Yep. One correction before the file, because it matters:

  • for a no-run signal bus, use chat.inject
  • not sessions.send

sessions.send goes through chat.send and can start a run. chat.inject appends a transcript message without waking the model, which is what you want here.

Direct answers to your 5 points

  1. Device identity persistence
    • Use a stable per-host file.
    • Shape matches OpenClaw’s own storage closely:
      • identity/device.json
      • JSON with version, deviceId, publicKeyPem, privateKeyPem, createdAtMs
    • Key algorithm: ed25519
    • deviceId: sha256(raw ed25519 public key), hex
    • Signed nonce payload format:
#

v3|deviceId|clientId|clientMode|role|scopesCsv|signedAtMs|token|nonce|platform|deviceFamily

  • Signature encoding: base64url
  1. First-run pairing handshake

    • The starter includes pairing detection, but not approval automation.
    • First connect uses:
      • shared gateway token from env/file
      • stable device identity
    • If the device is new, Gateway returns PAIRING_REQUIRED and creates a pending device request.
    • On the Gateway side you approve with:
      • openclaw devices list
      • openclaw devices approve <requestId>
    • After approval, next successful connect returns hello-ok.auth.deviceToken, and the daemon persists it.
  2. Secret loading

    • Starter supports:
      • GATEWAY_TOKEN
#
  • or GATEWAY_TOKEN_FILE
    • Recommendation:
      • Linux: plain file with 0600
      • Windows: per-user protected file under %APPDATA% for v1
    • I would not bother with Credential Manager in v1. If you want stronger at-rest protection later, swap the loader to DPAPI.
  1. Clean restart path

    • Restart reuses existing device identity and stored device token.
    • It does not re-pair on normal restart.
    • Re-pair only if:
      • device.json changes or is deleted
      • pairing entry is removed/revoked
      • you request broader role/scopes
      • Gateway decides device metadata changed enough to require re-approval
  2. Daemon-to-agent glue

    • I’d use a local file spool, not socket IPC, for v1.
#
  • Cross-platform, durable, dead simple.
    • Pattern:
      • agent writes outgoing JSON files to outbox/pending/
      • daemon injects them to Gateway and moves them to outbox/sent/
      • daemon writes incoming signals to inbox/pending/
      • agent consumes them and moves to inbox/acked/
    • That gives you a clean bridge without inventing a local protocol.

lobretta-control.ts

// deps: npm i ws
//
// Run with:
#
//   npx tsx lobretta-control.ts
//
// Env:
//   LOBRETTA_SELF=retta
//   GATEWAY_URL=wss://gateway-host:18789
//   GATEWAY_TOKEN=...                     # optional if device token already stored
//   GATEWAY_TOKEN_FILE=/path/to/token    # optional alternative
//   LOBRETTA_HOME=/path/to/state         # optional
//
// Important:
// - For pure signalling this uses chat.inject, not sessions.send.
// - Agent glue is a local file spool:
//     inbox/pending/*.json   <- daemon writes incoming signals here
//     inbox/acked/*.json     <- agent moves processed files here
//     outbox/pending/*.json  <- agent writes outgoing signals here
#
//     outbox/sent/*.json     <- daemon moves delivered files here

import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { WebSocket } from "ws";

const PROTOCOL_VERSION = 3;
const CLIENT_ID = "lobretta-control";
const CLIENT_MODE = "operator";
const DEVICE_ROLE = "operator";
const REQUESTED_SCOPES = ["operator.read", "operator.write"];
const SIGNAL_LABEL = "lobretta-signal";
#

const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");

type DeviceIdentity = {
  deviceId: string;
  publicKeyPem: string;
  privateKeyPem: string;
};

type StoredIdentity = {
  version: 1;
  deviceId: string;
  publicKeyPem: string;
  privateKeyPem: string;
  createdAtMs: number;
#
};

type DeviceAuthEntry = {
  token: string;
  role: string;
  scopes: string[];
  updatedAtMs: number;
};

type DeviceAuthStore = {
  version: 1;
  deviceId: string;
  tokens: Record<string, DeviceAuthEntry>;
};
#
type RpcErrorShape = {
  code?: string;
  message?: string;
  details?: any;
  retryable?: boolean;
  retryAfterMs?: number;
};

type OutboxSignal = {
  schema?: string;
  signalId?: string;
  from?: string;
  to: string;
  type: string;
  createdAt?: string;
#
  handoffId?: string;
  git?: {
    branch?: string;
    commit?: string;
    path?: string;
  };
  [key: string]: any;
};

type IncomingSignalEnvelope = {
  receivedAt: string;
  sessionKey: string;
  messageId?: string;
  messageSeq?: number;
  signal: OutboxSignal;
#
};

type SessionMessagePayload = {
  sessionKey: string;
  messageId?: string;
  messageSeq?: number;
  message?: {
    role?: string;
    label?: string;
    content?: Array<{ type?: string; text?: string }> | string;
  };
};

type Config = {
  self: string;
#
  gatewayUrl: string;
  stateHome: string;
  platform: string;
  deviceFamily: string;
  pollMs: number;
  rpcTimeoutMs: number;
};

class GatewayRpcError extends Error {
  code?: string;
  details?: any;
  retryable?: boolean;
  retryAfterMs?: number;

  constructor(err: RpcErrorShape) {
#
    super(err.message ?? "gateway rpc error");
    this.name = "GatewayRpcError";
    this.code = err.code;
    this.details = err.details;
    this.retryable = err.retryable;
    this.retryAfterMs = err.retryAfterMs;
  }
}

function deferred<T>() {
  let resolve!: (value: T) => void;
  let reject!: (reason?: unknown) => void;
  const promise = new Promise<T>((res, rej) => {
    resolve = res;
    reject = rej;
#
  });
  return { promise, resolve, reject };
}

function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function ensureDir(dir: string) {
  fs.mkdirSync(dir, { recursive: true });
}

function chmod0600BestEffort(filePath: string) {
  if (process.platform === "win32") return;
  try {
#
    fs.chmodSync(filePath, 0o600);
  } catch {}
}

function writeFile0600(filePath: string, content: string) {
  ensureDir(path.dirname(filePath));
  fs.writeFileSync(filePath, content, { mode: 0o600 });
  chmod0600BestEffort(filePath);
}

function atomicWriteJson(filePath: string, value: unknown) {
  const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
  writeFile0600(tmp, `${JSON.stringify(value, null, 2)}\n`);
  fs.renameSync(tmp, filePath);
}
#

function appendJsonl(filePath: string, value: unknown) {
  ensureDir(path.dirname(filePath));
  fs.appendFileSync(filePath, `${JSON.stringify(value)}\n`);
}

function sanitizeId(input: string) {
  return input.replace(/[^a-zA-Z0-9._-]+/g, "_");
}

function base64UrlEncode(buf: Buffer) {
  return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
}

function base64UrlDecode(input: string) {
#
  const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
  const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
  return Buffer.from(padded, "base64");
}

function normalizeScopes(scopes: string[]) {
  const out = new Set(scopes.map((s) => s.trim()).filter(Boolean));
  if (out.has("operator.admin")) {
    out.add("operator.read");
    out.add("operator.write");
  } else if (out.has("operator.write")) {
    out.add("operator.read");
  }
  return [...out].sort();
}
#

function derivePublicKeyRaw(publicKeyPem: string) {
  const key = crypto.createPublicKey(publicKeyPem);
  const spki = key.export({ type: "spki", format: "der" }) as Buffer;
  if (
    spki.length === ED25519_SPKI_PREFIX.length + 32 &&
    spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
  ) {
    return spki.subarray(ED25519_SPKI_PREFIX.length);
  }
  return spki;
}

function publicKeyRawBase64UrlFromPem(publicKeyPem: string) {
  return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
#
}

function fingerprintPublicKey(publicKeyPem: string) {
  const raw = derivePublicKeyRaw(publicKeyPem);
  return crypto.createHash("sha256").update(raw).digest("hex");
}

function signDevicePayload(privateKeyPem: string, payload: string) {
  const key = crypto.createPrivateKey(privateKeyPem);
  const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
  return base64UrlEncode(sig);
}

function buildDeviceAuthPayloadV3(params: {
  deviceId: string;
#
  clientId: string;
  clientMode: string;
  role: string;
  scopes: string[];
  signedAtMs: number;
  token?: string | null;
  nonce: string;
  platform?: string | null;
  deviceFamily?: string | null;
}) {
  return [
    "v3",
    params.deviceId,
    params.clientId,
    params.clientMode,
#
    params.role,
    params.scopes.join(","),
    String(params.signedAtMs),
    params.token ?? "",
    params.nonce,
    params.platform ?? "",
    params.deviceFamily ?? "",
  ].join("|");
}

function resolveStateHome() {
  const override = process.env.LOBRETTA_HOME?.trim();
  if (override) return path.resolve(override);

  if (process.platform === "win32") {
#
    const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
    return path.join(appData, "Lobretta");
  }

  return path.join(os.homedir(), ".lobretta");
}

function identityPath(cfg: Config) {
  return path.join(cfg.stateHome, "identity", "device.json");
}

function deviceAuthPath(cfg: Config) {
  return path.join(cfg.stateHome, "identity", "device-auth.json");
}
#
function gatewayTokenFilePath(cfg: Config) {
  return (
    process.env.GATEWAY_TOKEN_FILE?.trim() ||
    path.join(cfg.stateHome, "auth", "gateway-token")
  );
}

function inboxPendingDir(cfg: Config) {
  return path.join(cfg.stateHome, "inbox", "pending");
}

function inboxAckedDir(cfg: Config) {
  return path.join(cfg.stateHome, "inbox", "acked");
}
#
function inboxIndexPath(cfg: Config) {
  return path.join(cfg.stateHome, "inbox", "inbox.jsonl");
}

function outboxPendingDir(cfg: Config) {
  return path.join(cfg.stateHome, "outbox", "pending");
}

function outboxSentDir(cfg: Config) {
  return path.join(cfg.stateHome, "outbox", "sent");
}

function outboxFailedDir(cfg: Config) {
  return path.join(cfg.stateHome, "outbox", "failed");
}
#

function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity {
  try {
    if (fs.existsSync(filePath)) {
      const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as StoredIdentity;
      if (
        parsed?.version === 1 &&
        typeof parsed.deviceId === "string" &&
        typeof parsed.publicKeyPem === "string" &&
        typeof parsed.privateKeyPem === "string"
      ) {
        const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
        if (derivedId !== parsed.deviceId) {
          parsed.deviceId = derivedId;
          writeFile0600(filePath, `${JSON.stringify(parsed, null, 2)}\n`);
#
        }
        return {
          deviceId: derivedId,
          publicKeyPem: parsed.publicKeyPem,
          privateKeyPem: parsed.privateKeyPem,
        };
      }
    }
  } catch {
    // fall through and regenerate
  }

  const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
  const identity: DeviceIdentity = {
    deviceId: fingerprintPublicKey(publicKey.export({ type: "spki", format: "pem" }).toString()),
#
    publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
    privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
  };

  const stored: StoredIdentity = {
    version: 1,
    deviceId: identity.deviceId,
    publicKeyPem: identity.publicKeyPem,
    privateKeyPem: identity.privateKeyPem,
    createdAtMs: Date.now(),
  };

  writeFile0600(filePath, `${JSON.stringify(stored, null, 2)}\n`);
  return identity;
}
#

function loadStoredDeviceToken(filePath: string, deviceId: string, role: string) {
  try {
    if (!fs.existsSync(filePath)) return null;
    const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as DeviceAuthStore;
    if (parsed?.version !== 1 || parsed.deviceId !== deviceId) return null;
    const entry = parsed.tokens?.[role];
    if (!entry?.token) return null;
    return entry;
  } catch {
    return null;
  }
}

function storeDeviceToken(filePath: string, deviceId: string, role: string, token: string, scopes: string[]) {
#
  let current: DeviceAuthStore = { version: 1, deviceId, tokens: {} };

  try {
    if (fs.existsSync(filePath)) {
      const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as DeviceAuthStore;
      if (parsed?.version === 1 && parsed.deviceId === deviceId && parsed.tokens) {
        current = parsed;
      }
    }
  } catch {}

  current.tokens[role] = {
    token,
    role,
    scopes: normalizeScopes(scopes),
#
    updatedAtMs: Date.now(),
  };

  writeFile0600(filePath, `${JSON.stringify(current, null, 2)}\n`);
}

function loadExplicitGatewayToken(cfg: Config) {
  const fromEnv = process.env.GATEWAY_TOKEN?.trim();
  if (fromEnv) return fromEnv;

  const filePath = gatewayTokenFilePath(cfg);
  if (fs.existsSync(filePath)) {
    const raw = fs.readFileSync(filePath, "utf8").trim();
    if (raw) return raw;
  }
#

  return undefined;
}

function extractFirstTextBlock(message: any): string | null {
  if (!message) return null;
  if (typeof message.content === "string" && message.content.trim()) return message.content;
  if (!Array.isArray(message.content)) return null;

  for (const block of message.content) {
    if (block?.type === "text" && typeof block.text === "string" && block.text.trim()) {
      return block.text;
    }
  }
  return null;
#
}

function safeJsonParse<T>(raw: string): T | null {
  try {
    return JSON.parse(raw) as T;
  } catch {
    return null;
  }
}

function moveFile(src: string, dst: string) {
  ensureDir(path.dirname(dst));
  fs.renameSync(src, dst);
}
#
class GatewayLink {
  private cfg: Config;
  private identity: DeviceIdentity;
  private ws: WebSocket | null = null;
  private pending = new Map<
    string,
    {
      resolve: (value: any) => void;
      reject: (err: unknown) => void;
      timeout: NodeJS.Timeout;
    }
  >();
  private openGate = deferred<void>();
  private challengeGate = deferred<string>();
  private closedGate = deferred<{ code: number; reason: string }>();
#
  private connected = false;
  private stopped = false;
  private ownControlKey = "";
  private onSignal: (env: IncomingSignalEnvelope) => void;

  constructor(cfg: Config, identity: DeviceIdentity, onSignal: (env: IncomingSignalEnvelope) => void) {
    this.cfg = cfg;
    this.identity = identity;
    this.onSignal = onSignal;
  }

  get controlKey() {
    return this.ownControlKey;
  }
#
  isAlive() {
    return this.connected && this.ws?.readyState === WebSocket.OPEN;
  }

  async connect() {
    this.ws = new WebSocket(this.cfg.gatewayUrl);

    this.ws.on("open", () => this.openGate.resolve());
    this.ws.on("message", (raw) => this.handleMessage(raw.toString()));
    this.ws.on("error", (err) => {
      if (!this.connected) {
        this.openGate.reject(err);
        this.challengeGate.reject(err);
      }
    });
#
    this.ws.on("close", (code, reason) => {
      this.connected = false;
      const closeInfo = { code, reason: reason.toString() };
      this.closedGate.resolve(closeInfo);
      if (!this.connected) {
        this.openGate.reject(new Error(`websocket closed before ready: ${code} ${closeInfo.reason}`));
        this.challengeGate.reject(new Error(`challenge aborted: ${code} ${closeInfo.reason}`));
      }
      for (const [, pending] of this.pending) {
        clearTimeout(pending.timeout);
        pending.reject(new Error(`websocket closed: ${code} ${closeInfo.reason}`));
      }
      this.pending.clear();
    });
#
    await this.openGate.promise;
    const nonce = await this.challengeGate.promise;
    const hello = await this.rpc("connect", this.buildConnectParams(nonce));

    const issuedToken = hello?.auth?.deviceToken;
    const issuedScopes = Array.isArray(hello?.auth?.scopes) ? hello.auth.scopes : REQUESTED_SCOPES;
    if (issuedToken && typeof issuedToken === "string") {
      storeDeviceToken(deviceAuthPath(this.cfg), this.identity.deviceId, DEVICE_ROLE, issuedToken, issuedScopes);
    }

    this.connected = true;

    const created = await this.rpc("sessions.create", {
      key: `control:${this.cfg.self}`,
      label: `control:${this.cfg.self}`,
#
    });
    this.ownControlKey = created?.key ?? `control:${this.cfg.self}`;

    await this.rpc("sessions.messages.subscribe", { key: this.ownControlKey });
  }

  async close() {
    this.stopped = true;
    if (this.ws && this.ws.readyState === WebSocket.OPEN && this.ownControlKey) {
      try {
        await this.rpc("sessions.messages.unsubscribe", { key: this.ownControlKey });
      } catch {}
    }
    if (this.ws) {
      try {
#
        this.ws.close(1000, "shutdown");
      } catch {}
    }
  }

  async waitForClose() {
    return this.closedGate.promise;
  }

  async injectSignal(signal: OutboxSignal) {
    const targetSession = await this.rpc("sessions.create", {
      key: `control:${signal.to}`,
      label: `control:${signal.to}`,
    });
#
    const targetKey = targetSession?.key ?? `control:${signal.to}`;

    await this.rpc("chat.inject", {
      sessionKey: targetKey,
      message: JSON.stringify(signal),
      label: SIGNAL_LABEL,
    });
  }

  private buildConnectParams(nonce: string) {
    const explicitGatewayToken = loadExplicitGatewayToken(this.cfg);
    const stored = loadStoredDeviceToken(deviceAuthPath(this.cfg), this.identity.deviceId, DEVICE_ROLE);

    const resolvedDeviceToken = stored?.token;
    const authToken = explicitGatewayToken ?? resolvedDeviceToken;
#
    const signatureToken = authToken;

    if (!authToken) {
      throw new Error(
        `No connect credential found. Set GATEWAY_TOKEN or ${gatewayTokenFilePath(this.cfg)}, or pair once and keep ${deviceAuthPath(this.cfg)}.`,
      );
    }

    const signedAtMs = Date.now();
    const payload = buildDeviceAuthPayloadV3({
      deviceId: this.identity.deviceId,
      clientId: CLIENT_ID,
      clientMode: CLIENT_MODE,
      role: DEVICE_ROLE,
      scopes: REQUESTED_SCOPES,
#
      signedAtMs,
      token: signatureToken,
      nonce,
      platform: this.cfg.platform,
      deviceFamily: this.cfg.deviceFamily,
    });

    return {
      minProtocol: PROTOCOL_VERSION,
      maxProtocol: PROTOCOL_VERSION,
      client: {
        id: CLIENT_ID,
        version: "0.1.0",
        platform: this.cfg.platform,
        mode: CLIENT_MODE,
#
        deviceFamily: this.cfg.deviceFamily,
      },
      role: DEVICE_ROLE,
      scopes: REQUESTED_SCOPES,
      caps: [],
      auth: {
        token: authToken,
        ...(resolvedDeviceToken ? { deviceToken: resolvedDeviceToken } : {}),
      },
      device: {
        id: this.identity.deviceId,
        publicKey: publicKeyRawBase64UrlFromPem(this.identity.publicKeyPem),
        signature: signDevicePayload(this.identity.privateKeyPem, payload),
        signedAt: signedAtMs,
        nonce,
#
      },
    };
  }

  private handleMessage(raw: string) {
    const parsed = safeJsonParse<any>(raw);
    if (!parsed || typeof parsed !== "object") return;

    if (parsed.type === "event" && parsed.event === "connect.challenge") {
      const nonce = parsed.payload?.nonce;
      if (typeof nonce === "string" && nonce.trim()) {
        this.challengeGate.resolve(nonce.trim());
      }
      return;
    }
#

    if (parsed.type === "event" && parsed.event === "session.message") {
      const payload = parsed.payload as SessionMessagePayload;
      if (payload?.sessionKey !== this.ownControlKey) return;
      const text = extractFirstTextBlock(payload.message);
      if (!text) return;

      const signal = safeJsonParse<OutboxSignal>(text);
      if (!signal || typeof signal !== "object") return;

      this.onSignal({
        receivedAt: new Date().toISOString(),
        sessionKey: payload.sessionKey,
        messageId: payload.messageId,
        messageSeq: payload.messageSeq,
#
        signal,
      });
      return;
    }

    if (parsed.type === "res" && typeof parsed.id === "string") {
      const pending = this.pending.get(parsed.id);
      if (!pending) return;

      clearTimeout(pending.timeout);
      this.pending.delete(parsed.id);

      if (parsed.ok) {
        pending.resolve(parsed.payload);
      } else {
#
        pending.reject(new GatewayRpcError(parsed.error ?? { message: "unknown gateway error" }));
      }
    }
  }

  private rpc(method: string, params: any) {
    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
      throw new Error("websocket not open");
    }

    const id = crypto.randomUUID();
    const frame = { type: "req", id, method, params };

    return new Promise<any>((resolve, reject) => {
      const timeout = setTimeout(() => {
#
        this.pending.delete(id);
        reject(new Error(`rpc timeout: ${method}`));
      }, this.cfg.rpcTimeoutMs);

      this.pending.set(id, { resolve, reject, timeout });
      this.ws!.send(JSON.stringify(frame));
    });
  }
}

function ensureStateLayout(cfg: Config) {
  ensureDir(path.dirname(identityPath(cfg)));
  ensureDir(path.dirname(deviceAuthPath(cfg)));
  ensureDir(inboxPendingDir(cfg));
  ensureDir(inboxAckedDir(cfg));
#
  ensureDir(outboxPendingDir(cfg));
  ensureDir(outboxSentDir(cfg));
  ensureDir(outboxFailedDir(cfg));
}

function writeIncomingSignal(cfg: Config, env: IncomingSignalEnvelope) {
  const signalId = sanitizeId(env.signal.signalId || crypto.randomUUID());
  const pendingPath = path.join(inboxPendingDir(cfg), `${signalId}.json`);
  const ackedPath = path.join(inboxAckedDir(cfg), `${signalId}.json`);

  if (fs.existsSync(pendingPath) || fs.existsSync(ackedPath)) {
    return;
  }

  atomicWriteJson(pendingPath, env);
#
  appendJsonl(inboxIndexPath(cfg), env);
}

function listPendingJsonFiles(dir: string) {
  return fs
    .readdirSync(dir, { withFileTypes: true })
    .filter((d) => d.isFile() && d.name.endsWith(".json"))
    .map((d) => path.join(dir, d.name))
    .sort();
}

async function pumpOutbox(cfg: Config, link: GatewayLink) {
  const files = listPendingJsonFiles(outboxPendingDir(cfg));

  for (const file of files) {
#
    let raw: string;
    try {
      raw = fs.readFileSync(file, "utf8");
    } catch {
      continue;
    }

    const parsed = safeJsonParse<OutboxSignal>(raw);
    if (!parsed || typeof parsed !== "object" || !parsed.to || !parsed.type) {
      moveFile(file, path.join(outboxFailedDir(cfg), path.basename(file)));
      continue;
    }

    const signal: OutboxSignal = {
      schema: parsed.schema ?? "lobretta.v1.signal",
#
      signalId: parsed.signalId ?? path.basename(file, ".json"),
      from: parsed.from ?? cfg.self,
      createdAt: parsed.createdAt ?? new Date().toISOString(),
      ...parsed,
    };

    try {
      await link.injectSignal(signal);
      moveFile(file, path.join(outboxSentDir(cfg), `${sanitizeId(signal.signalId!)}.json`));
    } catch (err) {
      console.error(`[lobretta] outbox delivery failed for ${file}:`, err);
      break;
    }
  }
}
#

function logPairingHelp(cfg: Config, identity: DeviceIdentity, err: GatewayRpcError) {
  const details = err.details ?? {};
  const requestId = details.requestId ? String(details.requestId) : "<requestId>";
  const reason = details.reason ? String(details.reason) : "not-paired";
  const requestedRole = details.requestedRole ? String(details.requestedRole) : DEVICE_ROLE;
  const requestedScopes = Array.isArray(details.requestedScopes)
    ? details.requestedScopes.join(", ")
    : REQUESTED_SCOPES.join(", ");

  console.error("");
  console.error("[lobretta] pairing approval required");
  console.error(`  deviceId: ${identity.deviceId}`);
  console.error(`  role: ${requestedRole}`);
  console.error(`  scopes: ${requestedScopes}`);
#
  console.error(`  reason: ${reason}`);
  console.error(`  requestId: ${requestId}`);
  console.error("");
  console.error("On the Gateway host:");
  console.error("  openclaw devices list");
  console.error(`  openclaw devices approve ${requestId}`);
  console.error("");
  console.error(`Shared token source for first connect: ${gatewayTokenFilePath(cfg)} or GATEWAY_TOKEN`);
  console.error(`Persistent identity file: ${identityPath(cfg)}`);
  console.error(`Persistent device token store: ${deviceAuthPath(cfg)}`);
  console.error("");
}

function loadConfig(): Config {
  const self = process.env.LOBRETTA_SELF?.trim();
#
  const gatewayUrl = process.env.GATEWAY_URL?.trim();

  if (!self) throw new Error("LOBRETTA_SELF is required");
  if (!gatewayUrl) throw new Error("GATEWAY_URL is required");

  return {
    self,
    gatewayUrl,
    stateHome: resolveStateHome(),
    platform: process.platform,
    deviceFamily: process.env.LOBRETTA_DEVICE_FAMILY?.trim() || "lobretta",
    pollMs: Number(process.env.LOBRETTA_POLL_MS || 2000),
    rpcTimeoutMs: Number(process.env.LOBRETTA_RPC_TIMEOUT_MS || 15000),
  };
}
#

async function main() {
  const cfg = loadConfig();
  ensureStateLayout(cfg);

  const identity = loadOrCreateDeviceIdentity(identityPath(cfg));

  console.error(`[lobretta] self=${cfg.self}`);
  console.error(`[lobretta] stateHome=${cfg.stateHome}`);
  console.error(`[lobretta] deviceId=${identity.deviceId}`);

  let stopping = false;
  let current: GatewayLink | null = null;
  let backoffMs = 1000;
#
  const shutdown = async () => {
    if (stopping) return;
    stopping = true;
    console.error("[lobretta] shutting down");
    try {
      await current?.close();
    } finally {
      process.exit(0);
    }
  };

  process.on("SIGINT", shutdown);
  process.on("SIGTERM", shutdown);

  while (!stopping) {
#
    current = new GatewayLink(cfg, identity, (env) => writeIncomingSignal(cfg, env));

    try {
      await current.connect();
      console.error(`[lobretta] connected, subscribed to ${current.controlKey}`);
      backoffMs = 1000;

      while (!stopping && current.isAlive()) {
        await pumpOutbox(cfg, current);
        await sleep(cfg.pollMs);
      }

      if (!stopping) {
        const closed = await current.waitForClose();
        console.error(`[lobretta] disconnected: ${closed.code} ${closed.reason}`);
#
      }
    } catch (err) {
      if (err instanceof GatewayRpcError && err.details?.code === "PAIRING_REQUIRED") {
        logPairingHelp(cfg, identity, err);
        backoffMs = Math.max(backoffMs, 15000);
      } else {
        console.error("[lobretta] connect/run error:", err);
      }
    } finally {
      try {
        await current.close();
      } catch {}
      current = null;
    }
#
    if (!stopping) {
      await sleep(backoffMs);
      backoffMs = Math.min(backoffMs * 2, 30000);
    }
  }
}

main().catch((err) => {
  console.error("[lobretta] fatal:", err);
  process.exit(1);
});

Notes I’d carry into implementation

  • Linux token storage
#

Keep the shared token in:

  • ~/.lobretta/auth/gateway-token

  • mode 0600

  • Windows token storage
    For v1 I’d keep it in:

    • %APPDATA%\Lobretta\auth\gateway-token
    • rely on per-user profile ACLs
      I would only add DPAPI if you feel the host is materially exposed.
  • First-run approval UX
    Expect the daemon to print:

    • deviceId
    • requested role operator
    • requested scopes operator.read, operator.write
    • requestId if Gateway returns it
#
  • Restart semantics

    • keep identity/device.json
    • keep identity/device-auth.json
    • restart is then cheap and should not re-pair
  • Agent convention

    • outgoing: write one JSON file into outbox/pending/
    • incoming: read one JSON file from inbox/pending/, then move to inbox/acked/

If you want, I can do the next file too: a tiny systemd unit + wrapper for the atmt proof-of-life host.

#

Just dropped the starter above 👆

Key bit I’d keep in mind:

  • use chat.inject for the live signal hop, not sessions.send
  • persist:
    • identity/device.json
    • identity/device-auth.json
  • v1 glue is local inbox/outbox files
  • normal restarts reuse identity + device token, so no re-pair unless identity/scopes/approval changed

If you want, I can do the atmt systemd unit + wrapper next so you can deploy proof-of-life first.

wet flare
#

Starter accepted. The chat.inject correction is especially valuable, wouldn't have caught that without your pass.
Yes, please do the atmt systemd unit + wrapper next. atmt as the proof-of-life host makes sense since retta IS Lobretta on that box: loopback Gateway, no tailnet in the path, simplest possible pairing dance.
One nit before you write it: spec §11 has the session key as session:control:<agent>, the starter uses control:<agent> (no session: prefix). Which is canonical? I'll align whichever way you want but want to lock it before implementation.
After atmt is proof-of-life, I'll deploy Gitea on homelab, then roll the daemon to homelab (vsclaw) and Windows (clawco) with pairing approval on atmt's Gateway side.

worthy finchBOT
#

Yep, lock it like this:

  • request key convention: control:<agent>
  • canonical stored key: agent:main:control:<agent>

So for Retta:

  • request with control:retta
  • Gateway canonicalizes it to agent:main:control:retta

session:control:<agent> is not special, it would just become a longer stored key:

  • agent:main:session:control:retta

So I’d standardize on control:<agent> and update §11 mentally to that.

atmt proof-of-life: systemd user unit + wrapper

#

I’d use a systemd user unit, assuming Retta/Lobretta runs under the same Unix user that can already reach the local Gateway.

1) Wrapper

~/.local/bin/lobretta-retta-wrapper.sh

#!/usr/bin/env bash
set -euo pipefail

: "${LOBRETTA_SELF:=retta}"
: "${GATEWAY_URL:=ws://127.0.0.1:18789}"

export LOBRETTA_HOME="${LOBRETTA_HOME:-$HOME/.lobretta/retta}"
export APP_DIR="${APP_DIR:-$HOME/lobretta-control}"
export RUNNER="${RUNNER:-$APP_DIR/node_modules/.bin/tsx}"
#
export SCRIPT="${SCRIPT:-$APP_DIR/lobretta-control.ts}"
export GATEWAY_TOKEN_FILE="${GATEWAY_TOKEN_FILE:-$HOME/.config/lobretta/retta.gateway-token}"

mkdir -p \
  "$LOBRETTA_HOME/identity" \
  "$LOBRETTA_HOME/inbox/pending" \
  "$LOBRETTA_HOME/inbox/acked" \
  "$LOBRETTA_HOME/outbox/pending" \
  "$LOBRETTA_HOME/outbox/sent" \
  "$LOBRETTA_HOME/outbox/failed" \
  "$LOBRETTA_HOME/auth" \
  "$LOBRETTA_HOME/logs" \
  "$HOME/.config/lobretta"

if [[ -f "$GATEWAY_TOKEN_FILE" ]]; then
#
  chmod 600 "$GATEWAY_TOKEN_FILE" || true
fi

if [[ ! -x "$RUNNER" ]]; then
  echo "missing runner: $RUNNER" >&2
  exit 1
fi

if [[ ! -f "$SCRIPT" ]]; then
  echo "missing script: $SCRIPT" >&2
  exit 1
fi

exec "$RUNNER" "$SCRIPT"
#
chmod +x ~/.local/bin/lobretta-retta-wrapper.sh

2) Env file

~/.config/lobretta/retta.env

LOBRETTA_SELF=retta
GATEWAY_URL=ws://127.0.0.1:18789

If you want non-default paths, add them here too:

#
# optional overrides
# LOBRETTA_HOME=/home/you/.lobretta/retta
# APP_DIR=/home/you/lobretta-control
# RUNNER=/home/you/lobretta-control/node_modules/.bin/tsx
# SCRIPT=/home/you/lobretta-control/lobretta-control.ts
# GATEWAY_TOKEN_FILE=/home/you/.config/lobretta/retta.gateway-token

3) Gateway token file

~/.config/lobretta/retta.gateway-token

Put the shared Gateway token there, one line, then:

#
chmod 600 ~/.config/lobretta/retta.gateway-token

If you need to read the current token first:

openclaw config get gateway.auth.token

4) systemd user unit

~/.config/systemd/user/lobretta-retta.service

#
[Unit]
Description=Lobretta control daemon (retta)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
EnvironmentFile=%h/.config/lobretta/retta.env
ExecStart=%h/.local/bin/lobretta-retta-wrapper.sh
Restart=always
RestartSec=2
TimeoutStopSec=20
KillSignal=SIGTERM
WorkingDirectory=%h
StandardOutput=append:%h/.lobretta/retta/logs/stdout.log
#
StandardError=append:%h/.lobretta/retta/logs/stderr.log

[Install]
WantedBy=default.target

5) Enable it

systemctl --user daemon-reload
systemctl --user enable --now lobretta-retta.service
systemctl --user status lobretta-retta.service
journalctl --user -u lobretta-retta.service -f
#

Expected first-run behavior on atmt

Because this is loopback to the local Gateway, this is the easiest path.

Still, I would expect one of two outcomes:

  1. best case: local/trusted path auto-approves and it just connects
  2. normal case: you get one pending device approval, then:
    • openclaw devices list
    • openclaw devices approve <requestId>

After first successful connect it should persist:

  • ~/.lobretta/retta/identity/device.json
  • ~/.lobretta/retta/identity/device-auth.json

After that, normal restarts should reuse identity + device token, no re-pair.

#

What I’d do next

For atmt proof-of-life:

  1. put lobretta-control.ts in ~/lobretta-control/
  2. cd ~/lobretta-control && npm install ws tsx
  3. create the wrapper, env file, token file, and unit above
  4. start service
  5. drop a test file into:
    • ~/.lobretta/retta/outbox/pending/test.json

If you want, I can do one more tiny piece next: a known-good test.json outbox example plus the matching expected inbox artifact so you can verify the whole loop in one shot.

wet flare
#

Key convention locked: control:<agent> on request, agent:main:control:<agent> stored. Clean, adopting.
Yes, please do the test artifact next. Known-good test.json outbox payload plus matching expected inbox artifact makes atmt proof-of-life a one-shot verification rather than debug-blind. After that I'll execute the full deploy: install starter + wrapper + env + token + unit under the openclaw-agent user on atmt, loginctl enable-linger openclaw-agent, then drop the test file and watch the round-trip.
After atmt is green: Gitea on homelab, then daemon rollout to homelab (vsclaw) and Windows (clawco).

worthy finchBOT
#

Perfect. Here’s the one-shot proof-of-life artifact set.

1) Known-good outbox payload

Write this as:

~/.lobretta/retta/outbox/pending/pof-atmt-selftest-001.json

{
  "schema": "lobretta.v1.signal",
  "signalId": "pof-atmt-selftest-001",
  "from": "retta",
  "to": "retta",
  "type": "heartbeat",
  "createdAt": "2026-04-23T09:07:00Z",
#
  "note": "atmt proof-of-life self-test"
}

This is a self-send test, so it verifies the full path:

  • outbox file picked up
  • daemon injects to Gateway
  • Gateway appends to agent:main:control:retta
  • daemon receives subscribed session.message
  • inbox file written locally

2) Expected post-send result

A. outbox file should move to:

#

~/.lobretta/retta/outbox/sent/pof-atmt-selftest-001.json

B. inbox artifact should appear at:

~/.lobretta/retta/inbox/pending/pof-atmt-selftest-001.json

Expected shape:

{
  "receivedAt": "<runtime ISO timestamp>",
  "sessionKey": "agent:main:control:retta",
  "messageId": "<runtime message id>",
  "messageSeq": <runtime integer>,
  "signal": {
    "schema": "lobretta.v1.signal",
#
    "signalId": "pof-atmt-selftest-001",
    "from": "retta",
    "to": "retta",
    "type": "heartbeat",
    "createdAt": "2026-04-23T09:07:00Z",
    "note": "atmt proof-of-life self-test"
  }
}

The variable fields are:

  • receivedAt
  • messageId
  • messageSeq
#

Everything under signal should round-trip unchanged.

3) Quick verification checklist

You’re green if all of these happen:

  1. service log shows connected
  2. pending outbox file disappears
  3. sent outbox file appears
  4. pending inbox file appears
  5. inbox file contains:
    • "sessionKey": "agent:main:control:retta"
    • "signalId": "pof-atmt-selftest-001"

4) Tiny gotcha

Because the starter dedupes by inbox filename, if you rerun the same test id and this file already exists in either:

#
  • inbox/pending/
  • inbox/acked/

then you won’t see a new inbox write.

So for reruns, either:

  • delete/archive the old test file, or
  • bump the id, like pof-atmt-selftest-002

5) Optional shell check

If you want a super blunt pass/fail check:

test -f ~/.lobretta/retta/outbox/sent/pof-atmt-selftest-001.json &&
#
test -f ~/.lobretta/retta/inbox/pending/pof-atmt-selftest-001.json &&
grep -q '"sessionKey": "agent:main:control:retta"' ~/.lobretta/retta/inbox/pending/pof-atmt-selftest-001.json &&
echo "POF PASS"

If you want, after this I can do the homelab/vsclaw delta only, meaning just the parts that differ from atmt.

long questBOT
wet flare
#

Test artifact accepted. Executing on atmt shortly.
Yes, please do the homelab/vsclaw delta next, parallel to me running atmt. Expected differences: Gateway reached over tailnet instead of loopback, different user and home paths, and a pairing dance that actually needs manual approval on the Gateway side unlike atmt's trivial loopback case. Anything else that differs, name it.
Windows (clawco) will be its own delta when I get there. PowerShell wrapper, Task Scheduler or NSSM instead of systemd.

worthy finchBOT
#

Yep, the homelab delta is pretty small. The important differences are these.

Homelab / vsclaw delta

1) Identity and session naming

Change only the host identity bits:

  • LOBRETTA_SELF=vsclaw
  • request key: control:vsclaw
  • stored key: agent:main:control:vsclaw

State root becomes:

  • ~/.lobretta/vsclaw/

Token/env/service files become:

#
  • ~/.config/lobretta/vsclaw.env
  • ~/.config/lobretta/vsclaw.gateway-token
  • ~/.config/systemd/user/lobretta-vsclaw.service

2) Gateway URL is remote, not loopback

At atmt you used loopback:

  • ws://127.0.0.1:18789

On homelab, use the atmt Gateway over tailnet, for example:

  • ws://100.x.y.z:18789
  • or wss://gateway.your-tailnet-name.ts.net:18789
#

With the current starter, my recommendation is:

  • use tailnet-only ws://
  • do not expose this starter to public internet as-is

If later you want public wss://, I’d add TLS fingerprint pinning first.


3) Pairing is definitely manual

This is the biggest behavioral difference.

For homelab, expect:

  • first connect creates a pending paired-device request
  • it will not get the easy same-host loopback treatment
#

Approve it on the atmt Gateway side with:

openclaw devices list
openclaw devices approve <requestId>

You should expect the request to show roughly:

  • device id for homelab’s vsclaw identity
  • role: operator
  • scopes:
    • operator.read
    • operator.write

#

4) Keep the shared gateway token file in place

This is one difference I do want to name explicitly.

On homelab, I would keep:

  • ~/.config/lobretta/vsclaw.gateway-token

even after pairing succeeds and device-auth.json exists.

Why:

  • remote reconnects are less “special” than loopback
  • your current starter does not do TLS fingerprint pinning
  • keeping the shared token gives simpler reconnect behavior and clearer failure recovery

So for homelab, I would treat the shared token file as part of the steady-state config, not just bootstrap.

#

5) systemd shape stays the same

Same wrapper pattern, same user-unit pattern.

Only rename and repoint:

Env

~/.config/lobretta/vsclaw.env

LOBRETTA_SELF=vsclaw
GATEWAY_URL=ws://100.x.y.z:18789

Wrapper defaults

#

Use:

  • LOBRETTA_HOME=$HOME/.lobretta/vsclaw
  • GATEWAY_TOKEN_FILE=$HOME/.config/lobretta/vsclaw.gateway-token

Unit

Name it:

  • lobretta-vsclaw.service

6) Network timing matters more

This is minor, but real.

On atmt loopback, the Gateway is basically “there immediately”.

#

On homelab over tailnet, startup order is less deterministic.

So I’d keep:

  • After=network-online.target
  • Restart=always

And I’d be fine with either:

  • RestartSec=2
  • or RestartSec=5 if tailnet comes up slowly there

7) Homelab proof-of-life test

Same test pattern, just self-send to vsclaw.

#

Outbox file:

~/.lobretta/vsclaw/outbox/pending/pof-homelab-selftest-001.json

{
  "schema": "lobretta.v1.signal",
  "signalId": "pof-homelab-selftest-001",
  "from": "vsclaw",
  "to": "vsclaw",
  "type": "heartbeat",
  "createdAt": "2026-04-23T09:58:00Z",
  "note": "homelab proof-of-life self-test"
}

Expected inbox session key:

#
"sessionKey": "agent:main:control:vsclaw"

Netting it out

What actually changes from atmt:

  • remote GATEWAY_URL
  • vsclaw names/paths
  • manual device approval on atmt
  • keep shared gateway token file permanently
  • slightly more respect for network startup timing

Everything else is the same.

#

When you get there, I can do the Windows/clawco delta in the same format.

wet flare
#

Unrelated question on openclaw doctor warnings about Node coming from a version manager.

The gateway runs on an nvm-managed Node rather than a system-packaged one. Doctor flags this in three sections (Gateway runtime says "System Node 22 LTS or Node 24 not found," and Gateway service config warns that PATH includes a version manager and ExecStart points at nvm's Node). Recommendation is to install system Node via apt and migrate.

The guidance says the nvm setup "can break after upgrades." Two questions:

  1. What specifically breaks? My read: if the pinned Node version is removed or replaced (manual nvm uninstall or a subsequent nvm install cleanup), the systemd unit's ExecStart path goes stale and the gateway won't restart. As long as the pinned version stays put, ExecStart resolves and everything runs fine. Is there a failure mode beyond that — for example, openclaw update flows that bypass the unit and use whichever Node is in PATH at call time?

  2. Migrate proactively or revisit on breakage? The migration itself is cheap (apt Node + openclaw doctor --fix), but if the dormant risk only fires when I actively touch nvm, I'd rather leave a working system alone. Is there a scenario where the risk fires without me initiating a Node change?

worthy finchBOT
#

Your read is basically right.

1) What specifically breaks?

The main failure mode is exactly this:

  • the service unit points at an nvm-owned Node path
  • that path later disappears or changes
  • the gateway then fails on restart because ExecStart is stale

A few concrete variants of that:

  1. Versioned nvm path goes away
    • example: unit points at ~/.nvm/versions/node/v22.x.y/bin/node
    • you uninstall/prune that version
    • next restart fails
#
  1. current-style symlink moves

    • if the service ended up using something like ~/.nvm/current/bin/node
    • changing what current points to can silently change the runtime on next restart
    • that can be fine, or not, depending on where it lands
  2. A reinstall/refresh captures a different nvm Node

    • openclaw gateway install --force
    • the installer / some upgrade flows that refresh the service
    • these prefer the Node running the command
    • so if you run them from a different nvm-selected Node, the service can get rewritten to that path
  3. Older nvm installs had Linux CA-bundle weirdness

    • fetch() TLS failures because nvm Node used a bad/missing CA bundle
    • current OpenClaw tries to paper over this with NODE_EXTRA_CA_CERTS
    • but it is another reason doctor dislikes version-manager runtimes

So yes, the core issue is “stale service path”, but there is also “service got refreshed to a different transient nvm Node”.

#

One nuance on your openclaw update question:

  • openclaw update itself can run helper steps under whatever Node invoked it
  • but that does not automatically mean the installed service unit changes
  • the unit changes when you do a service reinstall/refresh path, explicitly or via installer-style post-install hooks

2) Migrate proactively or revisit on breakage?

My honest take:

  • not urgent if it’s working today
  • but worth migrating at some convenient maintenance window

I would frame it like this:

“Leave it alone for now” is reasonable if:

  • the gateway is stable
#
  • you are not actively changing nvm versions
  • you are not likely to prune old Node installs
  • you understand the service currently depends on that exact nvm runtime staying put

I’d migrate proactively if:

  • this box matters and you want boring restarts
  • you update OpenClaw regularly
  • multiple people/scripts touch Node on the host
  • you don’t want to think about whether the unit captured a transient nvm path

Does the risk fire without you initiating a Node change?

Usually, not by itself.

It is mostly triggered by one of:

  • changing/removing Node in nvm
#
  • reinstalling/refreshing the service from a different Node context
  • rebooting after one of the above exposed the stale path

So this is mostly a dormant fragility, not an active ticking bomb.

My recommendation

If you’re in the middle of other work, I would not stop everything to migrate.

But I would put it on the short list for the next quiet maintenance pass, because the fix is cheap and removes a dumb class of failure.

Docs:

If you want, I can give you the shortest safe migration sequence for Ubuntu/apt in one pasteable block.

wet flare
#

Thanks, clean breakdown. Confirmed the CA bundle fix is already in my unit file so that variant is mitigated. Leaving the nvm migration parked for a quiet maintenance pass.
Yes, please drop the shortest safe migration sequence for Ubuntu/apt. I'll file it as a ready-to-run playbook for when I get around to it, rather than re-deriving steps later.

worthy finchBOT
#

Yep. Here’s the shortest safe Ubuntu/apt playbook I’d keep on file.

Run it as the same user/profile that owns the gateway service.

# 0) Snapshot current state
openclaw --version
openclaw gateway status

# 1) Install system Node 24
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs

# 2) Confirm the system node exists
/usr/bin/node -v
#
# 3) Let doctor migrate the gateway service off nvm to system Node
openclaw doctor --fix

# 4) Restart onto the rewritten service config
openclaw gateway restart

# 5) Verify
openclaw gateway status --deep
openclaw doctor

What this does

  • installs supported system Node 24
  • lets doctor rewrite the gateway service to the recommended runtime
  • restarts the gateway on the rewritten service
#
  • verifies the warning is gone

Two practical notes

  1. Do not uninstall the old nvm Node immediately

    • leave it in place for one successful restart, maybe even one reboot
    • then remove it later if you want
  2. If doctor --fix reports it updated the gateway service, that is the key step

    • that is the migration
    • you should not need to manually re-derive the service unit yourself

Optional extra-paranoid verification

If you want one more check after restart: