keep-protocol 0.1.1__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keep/__init__.py +1 -1
- keep/client.py +311 -12
- keep_protocol-0.3.0.dist-info/METADATA +216 -0
- keep_protocol-0.3.0.dist-info/RECORD +7 -0
- keep_protocol-0.1.1.dist-info/METADATA +0 -178
- keep_protocol-0.1.1.dist-info/RECORD +0 -7
- {keep_protocol-0.1.1.dist-info → keep_protocol-0.3.0.dist-info}/WHEEL +0 -0
- {keep_protocol-0.1.1.dist-info → keep_protocol-0.3.0.dist-info}/top_level.txt +0 -0
keep/__init__.py
CHANGED
keep/client.py
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
"""Keep protocol client -- sign and send packets over TCP."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import socket
|
|
5
|
+
import struct
|
|
4
6
|
import uuid
|
|
5
|
-
from
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, Optional
|
|
6
10
|
|
|
7
11
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
8
12
|
|
|
9
13
|
from keep import keep_pb2
|
|
10
14
|
|
|
15
|
+
MAX_PACKET_SIZE = 65536
|
|
16
|
+
|
|
11
17
|
|
|
12
18
|
class KeepClient:
|
|
13
19
|
"""Client for the keep-protocol server.
|
|
14
20
|
|
|
15
21
|
Generates an ephemeral ed25519 keypair on init (or accepts an existing one).
|
|
16
22
|
Signs every outgoing packet.
|
|
23
|
+
|
|
24
|
+
Supports two modes:
|
|
25
|
+
- Ephemeral (default): opens/closes a TCP connection per send() call.
|
|
26
|
+
- Persistent: call connect() or use as context manager to hold a connection
|
|
27
|
+
open for sending and receiving routed messages via listen().
|
|
17
28
|
"""
|
|
18
29
|
|
|
19
30
|
def __init__(
|
|
@@ -22,29 +33,105 @@ class KeepClient:
|
|
|
22
33
|
port: int = 9009,
|
|
23
34
|
private_key: Optional[Ed25519PrivateKey] = None,
|
|
24
35
|
timeout: float = 10.0,
|
|
36
|
+
src: Optional[str] = None,
|
|
25
37
|
):
|
|
26
38
|
self.host = host
|
|
27
39
|
self.port = port
|
|
28
40
|
self.timeout = timeout
|
|
41
|
+
self.src = src or "bot:keep-client"
|
|
29
42
|
self._private_key = private_key or Ed25519PrivateKey.generate()
|
|
30
43
|
self._public_key = self._private_key.public_key()
|
|
31
44
|
self._pk_bytes = self._public_key.public_bytes_raw()
|
|
45
|
+
self._sock: Optional[socket.socket] = None
|
|
32
46
|
|
|
33
|
-
|
|
47
|
+
# -- Connection management --
|
|
48
|
+
|
|
49
|
+
def connect(self) -> None:
|
|
50
|
+
"""Open a persistent TCP connection to the server."""
|
|
51
|
+
if self._sock is not None:
|
|
52
|
+
return
|
|
53
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
54
|
+
s.settimeout(self.timeout)
|
|
55
|
+
s.connect((self.host, self.port))
|
|
56
|
+
self._sock = s
|
|
57
|
+
|
|
58
|
+
def disconnect(self) -> None:
|
|
59
|
+
"""Close the persistent connection."""
|
|
60
|
+
if self._sock is not None:
|
|
61
|
+
try:
|
|
62
|
+
self._sock.close()
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
self._sock = None
|
|
66
|
+
|
|
67
|
+
def __enter__(self) -> "KeepClient":
|
|
68
|
+
self.connect()
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def __exit__(self, *exc) -> None:
|
|
72
|
+
self.disconnect()
|
|
73
|
+
|
|
74
|
+
# -- Framing helpers --
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
|
78
|
+
"""Read exactly n bytes from sock."""
|
|
79
|
+
chunks = []
|
|
80
|
+
remaining = n
|
|
81
|
+
while remaining > 0:
|
|
82
|
+
chunk = sock.recv(min(remaining, 4096))
|
|
83
|
+
if not chunk:
|
|
84
|
+
raise ConnectionError(
|
|
85
|
+
f"Connection closed: expected {n} bytes, got {n - remaining}"
|
|
86
|
+
)
|
|
87
|
+
chunks.append(chunk)
|
|
88
|
+
remaining -= len(chunk)
|
|
89
|
+
return b"".join(chunks)
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _send_framed(sock: socket.socket, data: bytes) -> None:
|
|
93
|
+
"""Send data with a 4-byte big-endian length prefix."""
|
|
94
|
+
if len(data) > MAX_PACKET_SIZE:
|
|
95
|
+
raise ValueError(f"Packet too large: {len(data)} > {MAX_PACKET_SIZE}")
|
|
96
|
+
header = struct.pack(">I", len(data))
|
|
97
|
+
sock.sendall(header + data)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def _recv_framed(cls, sock: socket.socket) -> bytes:
|
|
101
|
+
"""Read a length-prefixed frame: 4-byte BE header + payload."""
|
|
102
|
+
header = cls._recv_exact(sock, 4)
|
|
103
|
+
(msg_len,) = struct.unpack(">I", header)
|
|
104
|
+
if msg_len == 0:
|
|
105
|
+
raise ConnectionError("Received zero-length frame")
|
|
106
|
+
if msg_len > MAX_PACKET_SIZE:
|
|
107
|
+
raise ConnectionError(f"Frame too large: {msg_len} > {MAX_PACKET_SIZE}")
|
|
108
|
+
return cls._recv_exact(sock, msg_len)
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def _read_packet(cls, sock: socket.socket) -> keep_pb2.Packet:
|
|
112
|
+
"""Read and parse one framed Packet from sock."""
|
|
113
|
+
data = cls._recv_framed(sock)
|
|
114
|
+
p = keep_pb2.Packet()
|
|
115
|
+
p.ParseFromString(data)
|
|
116
|
+
return p
|
|
117
|
+
|
|
118
|
+
# -- Signing --
|
|
119
|
+
|
|
120
|
+
def _sign_packet(
|
|
34
121
|
self,
|
|
35
122
|
body: str,
|
|
36
|
-
src: str =
|
|
123
|
+
src: Optional[str] = None,
|
|
37
124
|
dst: str = "server",
|
|
38
125
|
typ: int = 0,
|
|
39
126
|
fee: int = 0,
|
|
40
127
|
ttl: int = 60,
|
|
41
128
|
msg_id: Optional[str] = None,
|
|
42
129
|
scar: bytes = b"",
|
|
43
|
-
) ->
|
|
44
|
-
"""
|
|
130
|
+
) -> bytes:
|
|
131
|
+
"""Build, sign, and serialize a Packet. Returns wire bytes."""
|
|
45
132
|
msg_id = msg_id or str(uuid.uuid4())
|
|
133
|
+
src = src or self.src
|
|
46
134
|
|
|
47
|
-
# Build unsigned packet
|
|
48
135
|
p = keep_pb2.Packet()
|
|
49
136
|
p.typ = typ
|
|
50
137
|
p.id = msg_id
|
|
@@ -55,25 +142,237 @@ class KeepClient:
|
|
|
55
142
|
p.ttl = ttl
|
|
56
143
|
p.scar = scar
|
|
57
144
|
|
|
58
|
-
# Sign
|
|
59
145
|
sign_payload = p.SerializeToString()
|
|
60
146
|
sig_bytes = self._private_key.sign(sign_payload)
|
|
61
147
|
|
|
62
|
-
# Set sig + pk
|
|
63
148
|
p.sig = sig_bytes
|
|
64
149
|
p.pk = self._pk_bytes
|
|
65
|
-
|
|
150
|
+
return p.SerializeToString()
|
|
66
151
|
|
|
67
|
-
|
|
152
|
+
# -- Send --
|
|
153
|
+
|
|
154
|
+
def send(
|
|
155
|
+
self,
|
|
156
|
+
body: str,
|
|
157
|
+
src: Optional[str] = None,
|
|
158
|
+
dst: str = "server",
|
|
159
|
+
typ: int = 0,
|
|
160
|
+
fee: int = 0,
|
|
161
|
+
ttl: int = 60,
|
|
162
|
+
msg_id: Optional[str] = None,
|
|
163
|
+
scar: bytes = b"",
|
|
164
|
+
wait_reply: Optional[bool] = None,
|
|
165
|
+
) -> Optional[keep_pb2.Packet]:
|
|
166
|
+
"""Sign and send a packet.
|
|
167
|
+
|
|
168
|
+
In ephemeral mode (no connect() called): opens a connection, sends,
|
|
169
|
+
reads the reply, and closes. Always returns the server's reply.
|
|
170
|
+
|
|
171
|
+
In persistent mode: sends on the open connection.
|
|
172
|
+
- wait_reply=True: blocks until a reply is received and returns it.
|
|
173
|
+
- wait_reply=False: sends without waiting. Returns None.
|
|
174
|
+
- wait_reply=None (default): waits if dst is "server" or "",
|
|
175
|
+
does not wait otherwise.
|
|
176
|
+
"""
|
|
177
|
+
wire_data = self._sign_packet(
|
|
178
|
+
body=body,
|
|
179
|
+
src=src,
|
|
180
|
+
dst=dst,
|
|
181
|
+
typ=typ,
|
|
182
|
+
fee=fee,
|
|
183
|
+
ttl=ttl,
|
|
184
|
+
msg_id=msg_id,
|
|
185
|
+
scar=scar,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if self._sock is not None:
|
|
189
|
+
# Persistent mode
|
|
190
|
+
self._send_framed(self._sock, wire_data)
|
|
191
|
+
|
|
192
|
+
should_wait = wait_reply
|
|
193
|
+
if should_wait is None:
|
|
194
|
+
should_wait = dst in ("server", "") or dst.startswith("discover:")
|
|
195
|
+
|
|
196
|
+
if should_wait:
|
|
197
|
+
return self._read_packet(self._sock)
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
# Ephemeral mode — open/close per call
|
|
68
201
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
69
202
|
s.settimeout(self.timeout)
|
|
70
203
|
try:
|
|
71
204
|
s.connect((self.host, self.port))
|
|
72
|
-
|
|
73
|
-
reply_data =
|
|
205
|
+
self._send_framed(s, wire_data)
|
|
206
|
+
reply_data = self._recv_framed(s)
|
|
74
207
|
finally:
|
|
75
208
|
s.close()
|
|
76
209
|
|
|
77
210
|
resp = keep_pb2.Packet()
|
|
78
211
|
resp.ParseFromString(reply_data)
|
|
79
212
|
return resp
|
|
213
|
+
|
|
214
|
+
# -- Listen --
|
|
215
|
+
|
|
216
|
+
def listen(
|
|
217
|
+
self,
|
|
218
|
+
callback: Callable[[keep_pb2.Packet], None],
|
|
219
|
+
timeout: Optional[float] = None,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Block and read packets from the persistent connection.
|
|
222
|
+
|
|
223
|
+
Invokes callback(packet) for each received packet.
|
|
224
|
+
Heartbeat packets (typ=2) are silently filtered.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
callback: Called with each received Packet.
|
|
228
|
+
timeout: Seconds to listen before returning. None = listen until
|
|
229
|
+
the connection closes or an error occurs.
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
RuntimeError: If not connected (call connect() first).
|
|
233
|
+
"""
|
|
234
|
+
if self._sock is None:
|
|
235
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
236
|
+
|
|
237
|
+
if timeout is not None:
|
|
238
|
+
self._sock.settimeout(timeout)
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
while True:
|
|
242
|
+
p = self._read_packet(self._sock)
|
|
243
|
+
# Filter heartbeat packets
|
|
244
|
+
if p.typ == 2:
|
|
245
|
+
continue
|
|
246
|
+
callback(p)
|
|
247
|
+
except socket.timeout:
|
|
248
|
+
return
|
|
249
|
+
except ConnectionError:
|
|
250
|
+
return
|
|
251
|
+
finally:
|
|
252
|
+
if timeout is not None:
|
|
253
|
+
self._sock.settimeout(self.timeout)
|
|
254
|
+
|
|
255
|
+
# -- Discovery --
|
|
256
|
+
|
|
257
|
+
def discover(self, query: str = "info") -> dict:
|
|
258
|
+
"""Send a discovery query and return parsed JSON response.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
query: Discovery type — "info", "agents", or "stats".
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Parsed JSON dict from the server's response body.
|
|
265
|
+
"""
|
|
266
|
+
reply = self.send(body="", dst=f"discover:{query}")
|
|
267
|
+
return json.loads(reply.body)
|
|
268
|
+
|
|
269
|
+
def discover_agents(self) -> list:
|
|
270
|
+
"""Return list of currently connected agent identities."""
|
|
271
|
+
info = self.discover("agents")
|
|
272
|
+
return info.get("agents", [])
|
|
273
|
+
|
|
274
|
+
# -- Endpoint caching --
|
|
275
|
+
|
|
276
|
+
_CACHE_DIR = Path.home() / ".keep"
|
|
277
|
+
_CACHE_FILE = _CACHE_DIR / "endpoints.json"
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def cache_endpoint(host: str, port: int, info: dict) -> None:
|
|
281
|
+
"""Cache a discovered endpoint in ~/.keep/endpoints.json.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
host: Server hostname or IP.
|
|
285
|
+
port: Server port.
|
|
286
|
+
info: Server info dict (from discover("info")).
|
|
287
|
+
"""
|
|
288
|
+
cache_dir = Path.home() / ".keep"
|
|
289
|
+
cache_file = cache_dir / "endpoints.json"
|
|
290
|
+
|
|
291
|
+
# Load existing cache
|
|
292
|
+
endpoints = []
|
|
293
|
+
if cache_file.exists():
|
|
294
|
+
try:
|
|
295
|
+
data = json.loads(cache_file.read_text())
|
|
296
|
+
endpoints = data.get("endpoints", [])
|
|
297
|
+
except (json.JSONDecodeError, OSError):
|
|
298
|
+
endpoints = []
|
|
299
|
+
|
|
300
|
+
# Update or append
|
|
301
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
302
|
+
entry = {
|
|
303
|
+
"host": host,
|
|
304
|
+
"port": port,
|
|
305
|
+
"version": info.get("version", ""),
|
|
306
|
+
"agents_online": info.get("agents_online", 0),
|
|
307
|
+
"last_seen": now,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
updated = False
|
|
311
|
+
for i, ep in enumerate(endpoints):
|
|
312
|
+
if ep.get("host") == host and ep.get("port") == port:
|
|
313
|
+
endpoints[i] = entry
|
|
314
|
+
updated = True
|
|
315
|
+
break
|
|
316
|
+
if not updated:
|
|
317
|
+
endpoints.append(entry)
|
|
318
|
+
|
|
319
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
cache_file.write_text(json.dumps({"endpoints": endpoints}, indent=2))
|
|
321
|
+
|
|
322
|
+
@classmethod
|
|
323
|
+
def from_cache(
|
|
324
|
+
cls,
|
|
325
|
+
src: str = "bot:keep-client",
|
|
326
|
+
private_key: Optional[Ed25519PrivateKey] = None,
|
|
327
|
+
timeout: float = 5.0,
|
|
328
|
+
) -> "KeepClient":
|
|
329
|
+
"""Create a client by trying cached endpoints.
|
|
330
|
+
|
|
331
|
+
Reads ~/.keep/endpoints.json and attempts to connect to each
|
|
332
|
+
endpoint in order, returning the first successful connection.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
src: Agent identity for this client.
|
|
336
|
+
private_key: Optional ed25519 private key.
|
|
337
|
+
timeout: Connection timeout per endpoint attempt.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
A connected KeepClient instance.
|
|
341
|
+
|
|
342
|
+
Raises:
|
|
343
|
+
ConnectionError: If no cached endpoint is reachable.
|
|
344
|
+
"""
|
|
345
|
+
cache_file = Path.home() / ".keep" / "endpoints.json"
|
|
346
|
+
if not cache_file.exists():
|
|
347
|
+
raise ConnectionError("No cached endpoints (~/.keep/endpoints.json not found)")
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
data = json.loads(cache_file.read_text())
|
|
351
|
+
endpoints = data.get("endpoints", [])
|
|
352
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
353
|
+
raise ConnectionError(f"Failed to read endpoint cache: {e}")
|
|
354
|
+
|
|
355
|
+
if not endpoints:
|
|
356
|
+
raise ConnectionError("Endpoint cache is empty")
|
|
357
|
+
|
|
358
|
+
last_error = None
|
|
359
|
+
for ep in endpoints:
|
|
360
|
+
host = ep.get("host", "localhost")
|
|
361
|
+
port = ep.get("port", 9009)
|
|
362
|
+
try:
|
|
363
|
+
client = cls(
|
|
364
|
+
host=host,
|
|
365
|
+
port=port,
|
|
366
|
+
private_key=private_key,
|
|
367
|
+
timeout=timeout,
|
|
368
|
+
src=src,
|
|
369
|
+
)
|
|
370
|
+
# Test the connection
|
|
371
|
+
info = client.discover("info")
|
|
372
|
+
client.cache_endpoint(host, port, info)
|
|
373
|
+
return client
|
|
374
|
+
except (OSError, ConnectionError, json.JSONDecodeError) as e:
|
|
375
|
+
last_error = e
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
raise ConnectionError(f"No cached endpoint reachable (last error: {last_error})")
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keep-protocol
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Signed protobuf packets over TCP for AI agent-to-agent communication
|
|
5
|
+
Author: Chris Crawford
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/CLCrawford-dev/keep-protocol
|
|
8
|
+
Project-URL: Repository, https://github.com/CLCrawford-dev/keep-protocol
|
|
9
|
+
Project-URL: Issues, https://github.com/CLCrawford-dev/keep-protocol/issues
|
|
10
|
+
Keywords: agent-protocol,ai-agents,protobuf,ed25519,tcp,agent-communication,mcp,multi-agent
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Communications
|
|
21
|
+
Classifier: Topic :: Security :: Cryptography
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: protobuf>=4.25
|
|
26
|
+
Requires-Dist: cryptography>=41.0
|
|
27
|
+
|
|
28
|
+
# keep-protocol
|
|
29
|
+
|
|
30
|
+
**Signed Protobuf packets over TCP for AI agent-to-agent communication**
|
|
31
|
+
Claw to claw. Fast. Verifiable. No central authority.
|
|
32
|
+
|
|
33
|
+
> Now available on ClawHub: https://www.clawhub.ai/skills/keep-protocol
|
|
34
|
+
> (Search "keep-protocol" or tags: agent-coordination protobuf tcp ed25519 moltbot openclaw swarm intent)
|
|
35
|
+
|
|
36
|
+
Agents send lightweight `Packet`s to a TCP endpoint (default :9009).
|
|
37
|
+
Unsigned or invalid signatures → **silence** (dropped, no reply).
|
|
38
|
+
Valid ed25519 sig → parsed, logged, replied with `{"body": "done"}`.
|
|
39
|
+
|
|
40
|
+
### Packet (keep.proto)
|
|
41
|
+
|
|
42
|
+
```proto
|
|
43
|
+
message Packet {
|
|
44
|
+
bytes sig = 1; // ed25519 signature (64 bytes)
|
|
45
|
+
bytes pk = 2; // sender's public key (32 bytes)
|
|
46
|
+
uint32 typ = 3; // 0=ask, 1=offer, 2=heartbeat, ...
|
|
47
|
+
string id = 4; // unique ID
|
|
48
|
+
string src = 5; // "bot:my-agent" or "human:chris"
|
|
49
|
+
string dst = 6; // "server", "nearest:weather", "swarm:sailing"
|
|
50
|
+
string body = 7; // intent / payload
|
|
51
|
+
uint64 fee = 8; // micro-fee in satoshis (anti-spam)
|
|
52
|
+
uint32 ttl = 9; // time-to-live seconds
|
|
53
|
+
bytes scar = 10; // gitmem-style memory commit (optional)
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Signature is over serialized bytes without sig/pk (reconstruct & verify).
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
**Run server (Docker, one-liner):**
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
docker run -d -p 9009:9009 --name keep ghcr.io/clcrawford-dev/keep-server:latest
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Wire Format (v0.2.0+)
|
|
68
|
+
|
|
69
|
+
Every message on the wire is length-prefixed:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
[4 bytes: uint32 big-endian payload length][N bytes: protobuf Packet]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Maximum payload size: 65,536 bytes.
|
|
76
|
+
|
|
77
|
+
**Breaking change from v0.1.x:** Raw protobuf writes are no longer accepted. All clients must use length-prefixed framing.
|
|
78
|
+
|
|
79
|
+
### Python SDK Examples
|
|
80
|
+
|
|
81
|
+
**Install SDK:**
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install keep-protocol
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Unsigned send (will be silently dropped):**
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
# Raw unsigned send using generated bindings (requires keep_pb2.py from protoc)
|
|
91
|
+
import socket, struct
|
|
92
|
+
from keep.keep_pb2 import Packet
|
|
93
|
+
|
|
94
|
+
p = Packet(typ=0, id="test-001", src="human:test", dst="server", body="hello claw")
|
|
95
|
+
wire_data = p.SerializeToString()
|
|
96
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
97
|
+
s.connect(("localhost", 9009))
|
|
98
|
+
s.sendall(struct.pack(">I", len(wire_data)) + wire_data)
|
|
99
|
+
# → timeout / silence (unsigned = dropped)
|
|
100
|
+
s.close()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Signed send (recommended — uses KeepClient):**
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from keep import KeepClient
|
|
107
|
+
|
|
108
|
+
# Auto-generates keypair on first use
|
|
109
|
+
client = KeepClient("localhost", 9009)
|
|
110
|
+
|
|
111
|
+
reply = client.send(
|
|
112
|
+
body="ping from Python",
|
|
113
|
+
src="bot:python-test",
|
|
114
|
+
dst="server",
|
|
115
|
+
fee=1000 # optional anti-spam fee in sats
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
print(reply.body) # → "done"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Agent-to-Agent Routing (v0.2.0+)
|
|
122
|
+
|
|
123
|
+
Agents register their identity by sending any signed packet — the server maps `src` to the connection. Other agents can then send packets to that identity via `dst`.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import threading
|
|
127
|
+
from keep import KeepClient
|
|
128
|
+
|
|
129
|
+
# Agent A: listen for messages
|
|
130
|
+
with KeepClient(src="bot:alice") as alice:
|
|
131
|
+
alice.send(body="register", dst="server", wait_reply=True)
|
|
132
|
+
alice.listen(lambda p: print(f"Got: {p.body}"), timeout=30)
|
|
133
|
+
|
|
134
|
+
# Agent B: send to Alice (in another thread/process)
|
|
135
|
+
with KeepClient(src="bot:bob") as bob:
|
|
136
|
+
bob.send(body="register", dst="server", wait_reply=True)
|
|
137
|
+
bob.send(body="hello alice!", dst="bot:alice")
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**Routing rules:**
|
|
141
|
+
- `dst="server"` or `dst=""` → server replies `"done"` (backward compatible)
|
|
142
|
+
- `dst="bot:alice"` → forwarded to Alice's connection with original signature intact
|
|
143
|
+
- Destination offline → sender gets `body: "error:offline"`
|
|
144
|
+
- Delivery failure → sender gets `body: "error:delivery_failed"`
|
|
145
|
+
|
|
146
|
+
See `examples/routing_basic.py` for a full working demo.
|
|
147
|
+
|
|
148
|
+
## Discovery (v0.3.0+)
|
|
149
|
+
|
|
150
|
+
Agents can query the server for metadata and discover who's connected using `dst` conventions:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from keep import KeepClient
|
|
154
|
+
|
|
155
|
+
client = KeepClient("localhost", 9009)
|
|
156
|
+
|
|
157
|
+
# Server info: version, uptime, agent count
|
|
158
|
+
info = client.discover("info")
|
|
159
|
+
# → {"version": "0.3.0", "agents_online": 3, "uptime_sec": 1234}
|
|
160
|
+
|
|
161
|
+
# List connected agents
|
|
162
|
+
agents = client.discover_agents()
|
|
163
|
+
# → ["bot:alice", "bot:weather", "bot:planner"]
|
|
164
|
+
|
|
165
|
+
# Scar exchange stats
|
|
166
|
+
stats = client.discover("stats")
|
|
167
|
+
# → {"scar_exchanges": {"bot:alice": 5}, "total_packets": 42}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**Discovery conventions:**
|
|
171
|
+
| `dst` value | Response |
|
|
172
|
+
|-------------|----------|
|
|
173
|
+
| `"discover:info"` | Server version, agent count, uptime |
|
|
174
|
+
| `"discover:agents"` | List of connected agent identities |
|
|
175
|
+
| `"discover:stats"` | Scar exchange counts, total packets |
|
|
176
|
+
|
|
177
|
+
**Endpoint caching:** The SDK can cache discovered endpoints in `~/.keep/endpoints.json` for reconnection:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
# Cache after discovery
|
|
181
|
+
KeepClient.cache_endpoint("localhost", 9009, info)
|
|
182
|
+
|
|
183
|
+
# Reconnect from cache (tries each cached endpoint)
|
|
184
|
+
client = KeepClient.from_cache(src="bot:my-agent")
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
See `examples/discovery_basic.py` for a full working demo.
|
|
188
|
+
|
|
189
|
+
## Why Use It?
|
|
190
|
+
|
|
191
|
+
- **Local swarm:** Zero-latency handoff between agents on same machine.
|
|
192
|
+
- **Relay swarm:** Semantic routing via public/private relays (fee + ttl = spam control).
|
|
193
|
+
- **Memory barter:** `scar` field for sharing gitmem commits.
|
|
194
|
+
- **Identity without accounts:** Just a keypair — no registration.
|
|
195
|
+
- **No bloat:** Pure TCP + Protobuf, no HTTP/JSON overhead.
|
|
196
|
+
|
|
197
|
+
## OpenClaw / Moltbot Integration
|
|
198
|
+
|
|
199
|
+
Prompt your agent:
|
|
200
|
+
|
|
201
|
+
```text
|
|
202
|
+
Use keep-protocol to coordinate: send signed Packet to localhost:9009 body 'book sailing trip' src 'bot:me' dst 'swarm:sailing-planner' fee 1000 ttl 300
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Repo:** https://github.com/CLCrawford-dev/keep-protocol
|
|
206
|
+
**Docker:** `ghcr.io/clcrawford-dev/keep-server:latest`
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
**Active development happens here:** https://github.com/CLCrawford-dev/keep-protocol
|
|
211
|
+
Please open issues, PRs, and discussions on the original personal repo.
|
|
212
|
+
This nTEG-dev fork is a public mirror for visibility and ClawHub integration.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
🦀 Keep it signed. Keep it simple. Claw to claw.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
keep/__init__.py,sha256=x1tc3cuafZf6oOMwbtIX-9_qq4O4uyY_LNKhCxvVn8w,151
|
|
2
|
+
keep/client.py,sha256=B1lYu59ekZVubGyjXilozqis4Y3XlfFNUzWrMR6IO8A,11902
|
|
3
|
+
keep/keep_pb2.py,sha256=KQPLcH56ULH6qlEvhJLwmMBraqUb8hhMTWpmm9MvSO8,1298
|
|
4
|
+
keep_protocol-0.3.0.dist-info/METADATA,sha256=3mmQJ4kDNeIt8jtqbLOJp0GWwjhVWNnVMrEqjf8kRy4,7109
|
|
5
|
+
keep_protocol-0.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
+
keep_protocol-0.3.0.dist-info/top_level.txt,sha256=9mCnmW3qz7x1YOQkAFSorYLrAv4lqVBkJX4HCEvKy4U,5
|
|
7
|
+
keep_protocol-0.3.0.dist-info/RECORD,,
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: keep-protocol
|
|
3
|
-
Version: 0.1.1
|
|
4
|
-
Summary: Signed protobuf packets over TCP for AI agent-to-agent communication
|
|
5
|
-
Author: Chris Crawford
|
|
6
|
-
License: MIT
|
|
7
|
-
Project-URL: Homepage, https://github.com/CLCrawford-dev/keep-protocol
|
|
8
|
-
Project-URL: Repository, https://github.com/CLCrawford-dev/keep-protocol
|
|
9
|
-
Project-URL: Issues, https://github.com/CLCrawford-dev/keep-protocol/issues
|
|
10
|
-
Keywords: agent-protocol,ai-agents,protobuf,ed25519,tcp,agent-communication,mcp,multi-agent
|
|
11
|
-
Classifier: Development Status :: 3 - Alpha
|
|
12
|
-
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
-
Classifier: Programming Language :: Python :: 3
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
-
Classifier: Topic :: Communications
|
|
21
|
-
Classifier: Topic :: Security :: Cryptography
|
|
22
|
-
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
-
Requires-Python: >=3.9
|
|
24
|
-
Description-Content-Type: text/markdown
|
|
25
|
-
Requires-Dist: protobuf>=4.25
|
|
26
|
-
Requires-Dist: cryptography>=41.0
|
|
27
|
-
|
|
28
|
-
# keep-protocol
|
|
29
|
-
|
|
30
|
-
**Signed protobuf packets over TCP for AI agent-to-agent communication.**
|
|
31
|
-
|
|
32
|
-
[](LICENSE)
|
|
33
|
-
[](https://github.com/CLCrawford-dev/keep-protocol/actions)
|
|
34
|
-
|
|
35
|
-
> Keep is the quiet pipe agents whisper through.
|
|
36
|
-
> A single TCP connection, a tiny Protobuf envelope, an ed25519 signature —
|
|
37
|
-
> just enough fields to say who's talking, who should listen, what they want,
|
|
38
|
-
> how much they'll pay, and when the message expires.
|
|
39
|
-
> Unsigned packets vanish without a trace. Signed ones get heard.
|
|
40
|
-
|
|
41
|
-
---
|
|
42
|
-
|
|
43
|
-
## Install
|
|
44
|
-
|
|
45
|
-
### Python SDK
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
pip install keep-protocol
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
```python
|
|
52
|
-
from keep.client import KeepClient
|
|
53
|
-
|
|
54
|
-
client = KeepClient("localhost", 9009)
|
|
55
|
-
reply = client.send(
|
|
56
|
-
src="bot:my-agent",
|
|
57
|
-
dst="server",
|
|
58
|
-
body="hello from my agent",
|
|
59
|
-
)
|
|
60
|
-
print(reply.body) # "done"
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
### Run the Server
|
|
64
|
-
|
|
65
|
-
**Docker (recommended):**
|
|
66
|
-
```bash
|
|
67
|
-
docker run -d -p 9009:9009 --name keep ghcr.io/clcrawford-dev/keep-server:latest
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
**From source:**
|
|
71
|
-
```bash
|
|
72
|
-
git clone https://github.com/CLCrawford-dev/keep-protocol.git
|
|
73
|
-
cd keep-protocol
|
|
74
|
-
go build -o keep .
|
|
75
|
-
./keep # listens on :9009
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
### Verify It Works
|
|
79
|
-
|
|
80
|
-
```bash
|
|
81
|
-
pip install keep-protocol
|
|
82
|
-
python -c "
|
|
83
|
-
from keep.client import KeepClient
|
|
84
|
-
reply = KeepClient('localhost', 9009).send(body='ping')
|
|
85
|
-
print('OK' if reply.body == 'done' else 'FAIL')
|
|
86
|
-
"
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
---
|
|
90
|
-
|
|
91
|
-
## Why Keep?
|
|
92
|
-
|
|
93
|
-
| Feature | Keep | HTTP/REST | gRPC | NATS |
|
|
94
|
-
|---------|------|-----------|------|------|
|
|
95
|
-
| Latency | Sub-ms (TCP) | 1-10ms | 1-5ms | 1-5ms |
|
|
96
|
-
| Auth | ed25519 built-in | Bring your own | mTLS | Tokens |
|
|
97
|
-
| Schema | 10 fields, done | Unlimited | Unlimited | None |
|
|
98
|
-
| Setup | 1 binary, 0 config | Web server + routes | Codegen + server | Broker cluster |
|
|
99
|
-
| Agent-native | Yes | No | No | Partial |
|
|
100
|
-
| Spam resistance | fee + ttl fields | None | None | None |
|
|
101
|
-
|
|
102
|
-
Keep is not a replacement for gRPC or NATS. It is a protocol for agents that
|
|
103
|
-
need to find each other and exchange signed intent with minimal ceremony.
|
|
104
|
-
|
|
105
|
-
---
|
|
106
|
-
|
|
107
|
-
## Packet Schema
|
|
108
|
-
|
|
109
|
-
```protobuf
|
|
110
|
-
message Packet {
|
|
111
|
-
bytes sig = 1; // ed25519 signature (64 bytes)
|
|
112
|
-
bytes pk = 2; // sender's public key (32 bytes)
|
|
113
|
-
uint32 typ = 3; // 0=ask, 1=offer, 2=heartbeat
|
|
114
|
-
string id = 4; // unique message ID
|
|
115
|
-
string src = 5; // sender: "bot:my-agent" or "human:chris"
|
|
116
|
-
string dst = 6; // destination: "server", "nearest:weather", "swarm:planner"
|
|
117
|
-
string body = 7; // intent or payload
|
|
118
|
-
uint64 fee = 8; // micro-fee in sats (anti-spam)
|
|
119
|
-
uint32 ttl = 9; // time-to-live in seconds
|
|
120
|
-
bytes scar = 10; // gitmem-style memory commit (optional)
|
|
121
|
-
}
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
## Signing Protocol
|
|
125
|
-
|
|
126
|
-
Identity is a keypair. No accounts, no registration.
|
|
127
|
-
|
|
128
|
-
### Sending a signed packet
|
|
129
|
-
|
|
130
|
-
1. Build a `Packet` with all fields — leave `sig` and `pk` empty
|
|
131
|
-
2. Serialize to bytes — this is the **sign payload**
|
|
132
|
-
3. Sign those bytes with your ed25519 private key
|
|
133
|
-
4. Set `sig` (64 bytes) and `pk` (32 bytes) on the Packet
|
|
134
|
-
5. Serialize the full Packet — send over TCP
|
|
135
|
-
|
|
136
|
-
### Server verification
|
|
137
|
-
|
|
138
|
-
1. Unmarshal the incoming bytes into a `Packet`
|
|
139
|
-
2. If `sig` and `pk` are empty — **DROPPED** (logged, no reply)
|
|
140
|
-
3. Copy all fields except `sig`/`pk` into a new Packet, serialize it
|
|
141
|
-
4. Verify the signature against those bytes using the sender's `pk`
|
|
142
|
-
5. If invalid — **DROPPED** (logged, no reply)
|
|
143
|
-
6. If valid — process the message, send `done` reply
|
|
144
|
-
|
|
145
|
-
---
|
|
146
|
-
|
|
147
|
-
## Examples
|
|
148
|
-
|
|
149
|
-
See the [`examples/`](examples/) directory:
|
|
150
|
-
|
|
151
|
-
- **[python_basic.py](examples/python_basic.py)** — Minimal SDK usage
|
|
152
|
-
- **[python_raw.py](examples/python_raw.py)** — Raw TCP + signing without the SDK (educational)
|
|
153
|
-
- **[mcp_tool_definition.py](examples/mcp_tool_definition.py)** — Expose keep as an MCP tool
|
|
154
|
-
|
|
155
|
-
---
|
|
156
|
-
|
|
157
|
-
## Use Cases
|
|
158
|
-
|
|
159
|
-
- **Local swarm** — agents on same VM use `localhost:9009` for zero-latency handoff
|
|
160
|
-
- **Relay swarm** — agents publish to public relays — relays enforce fee/ttl/reputation
|
|
161
|
-
- **Memory sharing** — `scar` field carries gitmem-style commits — agents barter knowledge
|
|
162
|
-
- **Anti-spam market** — `fee` field creates micro-economy — pay to get priority
|
|
163
|
-
|
|
164
|
-
## Design Principles
|
|
165
|
-
|
|
166
|
-
- **Silent rejection** — unsigned senders don't know if the server exists
|
|
167
|
-
- **Identity without accounts** — your keypair is your identity
|
|
168
|
-
- **Full visibility** — dropped packets are logged server-side
|
|
169
|
-
- **Minimal overhead** — protobuf over raw TCP, no HTTP/JSON
|
|
170
|
-
- **Semantic routing** — `dst` is a name, not an address
|
|
171
|
-
|
|
172
|
-
## Contributing
|
|
173
|
-
|
|
174
|
-
We welcome contributions. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
175
|
-
|
|
176
|
-
## License
|
|
177
|
-
|
|
178
|
-
MIT. See [LICENSE](LICENSE).
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
keep/__init__.py,sha256=T_ClQjBNmRMe-k_5BHGOBQIEbf1NeAbogCdlvmk6f7E,151
|
|
2
|
-
keep/client.py,sha256=BhuvazpKR5wKVvwPZxpIfWTJsC8cU3TE7GDhlfr3stc,2109
|
|
3
|
-
keep/keep_pb2.py,sha256=KQPLcH56ULH6qlEvhJLwmMBraqUb8hhMTWpmm9MvSO8,1298
|
|
4
|
-
keep_protocol-0.1.1.dist-info/METADATA,sha256=2GrKNS5be5P-3TUqOHVKbFLgc5ZwAgNgMO99j4hpyaU,5855
|
|
5
|
-
keep_protocol-0.1.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
-
keep_protocol-0.1.1.dist-info/top_level.txt,sha256=9mCnmW3qz7x1YOQkAFSorYLrAv4lqVBkJX4HCEvKy4U,5
|
|
7
|
-
keep_protocol-0.1.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|