Skip to content

feat(openclaw): add OpenClaw as an alternative agent runtime - #35

Open
jannikluhn wants to merge 10 commits into
feat/parallelizationfrom
feat/openclaw
Open

jannikluhn wants to merge 10 commits into
feat/parallelizationfrom
feat/openclaw

Conversation

@jannikluhn

@jannikluhn jannikluhn commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Built on #34 (feat/parallelization); merge that PR first. Most of this PR is tests, test helpers, an example, and documentation for the new runtime.

What this does

Adds createOpenClawRuntime, exported from @shutter-network/concorde/openclaw, as an alternative to the pi runtime. It connects Concorde to an OpenClaw daemon. The gateway, components, and signal handlers work with either runtime.

To switch the application to OpenClaw, replace the runtime constructor:

-const runtime = createPiRuntime({ host, port, sessionsDir })
+const runtime = createOpenClawRuntime({
+  url: "ws://agent:18789",
+  token: process.env.OPENCLAW_GATEWAY_TOKEN,
+  deviceKey: createPrivateKey(readFileSync(DEVICE_KEY_FILE)), // must be an Ed25519 private key
+  agentId: "concorde", // optional if the daemon hosts one agent
+})

One run, end to end

sequenceDiagram
    participant W as Signal worker
    participant R as OpenClaw runtime
    participant D as OpenClaw daemon
    W->>R: run(prompt)
    R->>D: Connect with token and signed device challenge (operator.read + operator.write)
    R->>D: sessions.messages.subscribe { key: session }
    R->>D: chat.send { sessionKey: session, message, deliver: false }
    D-->>R: session.message with usage and __openclaw.runId, once per model call
    D-->>R: chat with state final, error, or aborted and runId
    R->>D: Close connection
    R-->>W: { ok, usage[], limited? }
Loading

The daemon broadcasts events for multiple runs over each connection. The runtime filters run events by runId and usage records by __openclaw.runId. It subscribes before sending the prompt so it does not miss usage records.

Every send uses deliver: false, which disables replies through OpenClaw's own channels. The agent must use Concorde's messenger to send messages to users.

How it differs from pi

pi OpenClaw
Agent process A listener starts one process per connection. All runs use the same daemon, with a separate connection per run.
Connection authentication None. A shared token and a signed Ed25519 device challenge. Approve the device once on the daemon host; the runtime writes no credentials to disk.
Session names Names become files under sessionsDir and follow pi's naming rules. Names become the last part of agent:<agentId>:<name>. They must be nonblank, lowercase, at most 512 characters, and contain no colons or surrounding whitespace. main and global are reserved. These checks prevent OpenClaw's name normalization from merging distinct sessions.
Usage reporting Reported directly on the RPC stream. Read from model-call records in the session subscription and converted to the same usage format.
Usage limit Aborts the run and reports partial usage for the interrupted call. Sends chat.abort. OpenClaw does not report usage for the interrupted call, so the recorded total can understate the run's cost.

Each runtime now validates its own session names. Both accept run_<uuid>, the format the worker uses when it creates a session. Tests cover that shared requirement.

What is in the tree

src/openclaw/
├── runtime.ts             # validation, run lifecycle, and usage limits
├── connection.ts          # official client, device signing, and connection failures
├── session-names.ts       # session name validation
├── usage.ts               # convert session.message records to UsageReport
├── *.test.ts              # tests using a simulated daemon
└── agent-instance.test.ts # tests using the real daemon image, pinned to 2026.8.1
src/test-support/
├── openclaw-daemon.ts     # in-process WebSocket daemon for tests
└── openclaw-runtime.ts    # shared test helpers
examples/06_openclaw/      # minimal application, with pairing instructions in its README

Adds @openclaw/gateway-client and @openclaw/gateway-protocol at ^2026.8.1. Imports are restricted to src/openclaw/ by the Biome configuration and a test. Connections require a matching protocol version, so an incompatible daemon produces a connection error. The container test uses a pinned daemon image.

Verification

Validation reported for this branch:

  • npm run check: 634 tests pass.
  • npm run check:package and npm run check:docs pass.
  • npm run test:container tests a real OpenClaw daemon: an unapproved device gets pairing instructions, approval through the daemon's CLI allows subsequent runs, two prompts continue the same session and return usage, and exceeding a usage limit returns limited: true with one usage record.
  • The 06_openclaw example was run under Compose using a locally packed package. Device pairing, pairing approval surviving a restart, and a run reaching the model provider were checked. Sending an agent reply through the messenger was not verified because no model key was available.

Notes for the reviewer

  • The example installs Concorde from the registry. It needs a release that includes /openclaw before it can run without local package overrides.
  • The existing intermittent failure in the database listener tests remains: it appears in the full suite, while the tests pass on their own.
  • totalTokens is calculated as input + output + cacheRead + cacheWrite, as required by UsageReport. This also matches the observed daemon records.
  • The glossary, architecture document, and editing guide now describe connections for both pi and OpenClaw.

jannikluhn and others added 10 commits November 9, 2026 13:53
A Session name's constraint comes from what the name becomes in a Runtime,
a file path under pi and a session key under OpenClaw, so there is no
framework-wide grammar. A Runtime fails a Run whose name it cannot address
and accepts the shape the Worker mints for a fresh Session.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Signal Worker names a fresh Session run_<uuid> and every Runtime must
accept that shape; the Runtime contract now says so. The pi Runtime's doc
comment presents its name grammar as its own, with the reason: the name
becomes a file under sessionsDir. A test hands the Worker's mint shape
through run against the scripted RPC server and proves it is not refused.
The editing guide's note on re-reading pi's session id check says why the
grammar is pi's alone. No OpenClaw code and no change to the regex.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… back

A second Runtime, createOpenClawRuntime on the /openclaw subpath, takes the
daemon URL, the shared token, one Ed25519 private KeyObject, an optional agent
id and an optional logger. Each Run opens one connection through the official
client package, signs the daemon's challenge with hooks from node:crypto,
subscribes to the Session, sends the Prompt with deliver false and follows the
chat frames for its own runId to the first final, error or aborted one, then
closes the connection. The Session name is passed through verbatim; the daemon
prefixes it. Construction refuses a key that is not an Ed25519 private key.

The scopes requested are operator.read and operator.write, because a
token-only connection is accepted and then refused every method. Every
connection receives every Run's frames, so a frame counts only by its runId.

Test support gains a scripted daemon: an in-process WebSocket server that
plays the challenge, verifies the device signature the way the daemon does,
answers connect, the subscription and chat.send, numbers its events, records
every request and can be told to refuse or hang up. The two OpenClaw packages
are regular dependencies with caret ranges, confined to src/openclaw by the one
Biome overrides entry, proven by the import-confinement test, the package
check and the reference documentation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ailure

The OpenClaw Runtime's outcome now carries one UsageReport per model call
the Run made, read from the session.message transcript records the daemon
streams to the connection that subscribed to the Session before chat.send.
A record belongs to the Run by its __openclaw.runId and never by order,
since the connection hears every Run in the Session. The records collected
until the Run's first terminal frame are its Usage, whether that frame was
final, error or aborted, or the connection dropped; every record of a Run
arrived before its terminal frame in every observed Run, so nothing waits
after it. Provider, model and the four token counts are passed on as sent,
cost is the daemon's cost.total, and totalTokens is summed from the four
counts as the type promises. The daemon's all-zero record for a refused or
cut-off call is kept as a call that charged nothing. The session-level usage
method is never called, and UsageReport is unchanged.

The reading lives in src/openclaw/usage.ts, one function from a payload and
a run id to a report or nothing. The scripted daemon gains the observed
session.message shape: modelCall builds one assistant record, modelCalls
plays a Run's calls in the observed order with the Prompt's user record and
the agent usage frames between, refusedCall is the daemon's zero record, and
the daemon stamps the Run's id into __openclaw and the session key beside
the message as it does into chat and agent frames. The tests drive run
against the scripted daemon for a three-call Run, a Run refused after two
calls, an abort, a dropped connection, another Run's records and the
session-level method, and one test runs a Signal through createSignalWorker
with this Runtime and reads the saved Usage back through the Usage
Component, as a pi Run's reads back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…not act on

The OpenClaw Runtime now carries a Session name grammar of its own, in
src/openclaw/session-names.ts, decided from what daemon 2026.8.1 was observed
to do with a key rather than transcribed from a rule of its own. Refused, with
the Run failing before any connection and the message naming the rule: a blank
or whitespace-only name, one over 512 characters, any colon (the daemon's key
segment separator, which keeps the agent:, subagent:, cron: and acp: forms out
of a Handler's reach), the names main and global (the agent's main Session,
into which the daemon folds global), any uppercase letter (the daemon
lowercases every key, so User_42 and user_42 would be one Session) and leading
or trailing whitespace (the daemon trims it). Dots, dashes, underscores,
slashes, inner spaces, non-ASCII and the incognito- prefix pass through
verbatim, as does the run_<uuid> the Signal Worker mints. The Runtime's doc
comment states each refusal and its reason, including that the daemon
lowercases.

A usageLimit that is not a positive whole number fails the Run before any
connection, worded as under pi. A connect refused with
details.code PAIRING_REQUIRED fails the Run with the pairing request id and
the command that approves it on the daemon host, openclaw devices approve
<requestId>, in place of the client's bare message.

The new cases are proven in src/openclaw/failures.test.ts against the
scripted daemon: eighteen refused names and eleven accepted ones, the five
invalid limits pi refuses, and the pairing refusal in the frame shape the
prototype recorded, with the connection closed and nothing sent after it.
Every case is an ok: false outcome and no test observes a throw from run. The
one existing fixture with an uppercase letter is lowercased, and the package
check lists the new shipped module.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The one test that talks to the real thing: the pinned OpenClaw daemon
(ghcr.io/openclaw/openclaw:2026.8.1, named once in src/test-support/docker.ts)
runs in Docker with the existing scripted model as its only provider, so no
model credential is needed. The first Run from a fresh Ed25519 key is refused
with the Runtime's pairing message, the request id is read out of that message
and approved with `devices approve` inside the container, the next Run returns
ok with the usage the model's counts imply, and a second Prompt into the same
Session reaches the model with the first Prompt and the first answer in view.

The container runs as the image's own user, since the daemon dies under
`--user <host uid>` creating /home/node/.cache; the state directory is opened
to it instead. It is skipped unless CONCORDE_CONTAINER_TESTS opts in and runs
from `npm run test:container` beside the pi test, in about eight seconds.

Also lists dist/openclaw/usage.js and .d.ts among the shipped files, which the
usage module of the previous change left out of check-package.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Each session.message record of the Run is added to its total as it arrives, and the record that takes the total past the Prompt's usageLimit sends chat.abort with the session key, the configured agent and the run id. The Run is followed to its terminal frame and fails with limited: true and every report collected until then, in pi's wording. The daemon starts no further call once it accepts the abort and the call in flight never reports usage, so the overshoot is that one call and a limited Run's recorded Usage can sit below the cap; the doc comment says so. An aborted frame the Runtime did not cause stays an ordinary failure, and a Run that finished or failed before the abort landed is limited with a message saying which.

The fake daemon gains an abortable scripted Run, held open per call and ended by chat.abort with the frames Finding 5 recorded, and the scripted model a tool-call reply, so the container test runs a limited two-call Run against the real daemon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The glossary's Agent server entry no longer calls the Agent Instance's
address unauthenticated as a rule: that is pi's RPC, which has no
authentication of any kind, while the OpenClaw daemon's address is
authenticated by a signed challenge with a device key the Operator
provisions and approves once, the daemon's shared token and scopes. The
Agent Instance entry reaches "the address it accepts connections on"
rather than an RPC channel, and the Usage limit entry says that whether
the stopped call's tokens are in the outcome is the Agent
Implementation's doing, pi reporting the partial call and the OpenClaw
daemon nothing.

The architecture document gains a section on running OpenClaw as the
agent implementation: the one daemon process every run shares against
pi's listener that forks per connection, the authenticated address, what
the operator provisions and the one-time approval on the daemon host,
the plaintext ws:// rule, delivery being off so the messenger stays the
only path to a user, the session name as the last segment of the
daemon's key and the daemon's lowercasing as the reason uppercase names
are refused, and where usage and the usage limit differ from pi. The
sentences calling the agent instance's RPC listener unauthenticated, in
the architecture document, the README and the site index, now hold for
both; the README's runtime paragraph names both runtimes.

The editing guide gains the silent failures this feature made: the
OpenClaw Runtime's own Session grammar going stale against a newer
daemon, deliver: false on every chat.send, the host hooks the client
package leaves to its embedder and the identity hook whose undefined
default makes a remote daemon accept an identity-less connection with
zero scopes, the subscription that must precede chat.send, every
connection hearing every Run's frames, and the caret on the
calendar-versioned @openclaw/* dependencies and why it stays.

The Runtime's doc comment said a limited Run's recorded Usage can sit
below the cap it was stopped for; the record that sends the abort is the
one that took the total past the limit, so the records already exceed
it. What the stopped call costs is what is missing, and the sentence now
says the recorded Usage understates what the Run cost.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Example 06 is the minimal HTTP application with createOpenClawRuntime in
place of createPiRuntime: the daemon from its published image at one
pinned version, paired once by an Ed25519 device key the Gateway loads
from a mounted PEM, reached by service name over the private network
with OPENCLAW_ALLOW_INSECURE_PRIVATE_WS set, configured from a read-only
JSON file that resolves the shared token and the Anthropic key from the
environment, with no channels, the control UI off and its state on a
named volume so the approval survives restarts. The README states what
differs from running pi, how to generate a key, and the one approve
command.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A socket the daemon closes before hello-ok without a connect error
reaches only the client's onClose hook, where the Runtime rejected the
opening promise and left the client running, so it reconnected every
second for as long as the process lived after the Run had failed. The
hook now stops the client before it returns, as the onConnectError
branch does, which is before the client schedules the reconnection. The
fake daemon can close a socket before its challenge, and a test holds
the Run to one failed connection and no second one.

The close in run's finally is the client's stopAndWait, which rejects
after its own timeout if the socket is still open; unguarded, that
rejection would have replaced the outcome and its Usage. It is caught
and logged, and the outcome stands. No test: the client arms a 250 ms
force-terminate that resolves the stop before its timeout can fire, so
the rejection is not reachable through the fake daemon.

The connection target loses the agentId it never read, KeyObject is
imported once as the value it is, the container test's daemon
configuration drops the retired default agent marker to match the
example's, the fake daemon's scope map loses the sessions.list entry
nothing sends, and the session name cap stops being exported since the
tests state the literal. The Session grammar is stated once, in the
createOpenClawRuntime doc comment the reference site renders, and
session-names.ts points at it. The helpers every OpenClaw test file
copied live in src/test-support/openclaw-runtime.ts, waitFor gives way
to waitUntil, and the comments that pointed at numbered findings in a
file outside the tree state the fact instead. The example's README says
the daemon's container and session where it said agent container and
conversation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jannikluhn jannikluhn changed the title feat(openclaw): add an OpenClaw Runtime beside pi feat(openclaw): add OpenClaw as an alternative agent runtime Nov 9, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant