agentknock 0.6.0

Developer secrets on your phone, provided only to approved commands.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
# Agentknock v1 client-relay protocol

This document defines the WebSocket protocol between an Agentknock client and
a relay. It covers initial-pairing connections, authenticated client
connections, message transfer, acknowledgement, reconnection, and the relay
state visible to a client.

The device-facing relay protocol is outside this document. The
[Agentknock v1 client-device protocol](client-device-protocol.md) defines the
opaque payloads carried through the relay. The [Agentknock v1
cryptosystem](cryptosystem.md) defines their cryptographic protection.

## Delivery model

The relay provides store-and-forward delivery. The client and device do not
need to be online at the same time.

Each client-originated message crosses two separate handoffs:

```text
Client -> relay -> device
```

The relay sends an `ack` frame after it accepts responsibility for a message.
It sends a `receipt` frame after the device accepts responsibility. Neither
frame means that a user saw the request or that application processing
succeeded.

The relay provides at-least-once transfer while an exchange is active.
Duplicate frames and messages are normal. Stable message identities and the
first-accepted-payload rule make retries idempotent; endpoint application
logic remains responsible for processing each message once.

The relay treats every application `payload` as an opaque JSON value. It does
not interpret cryptographic envelope members, protected methods, results,
errors, or secret contents.

## Identifiers

The client-facing protocol uses the following identifiers:

| Identifier | Representation | Purpose |
| --- | --- | --- |
| `address_id` | 32 lowercase hexadecimal characters | Selects the discovery alias used to start pairing. |
| `device_id` | Canonical uppercase ULID | Selects the paired device's relay mailbox. |
| `client_id` | Canonical uppercase ULID | Selects one client record within a device mailbox. |
| `request_id` | Canonical uppercase ULID | Selects one application exchange. |

The canonical ULID form matches:

```text
[0-7][0-9A-HJKMNP-TV-Z]{25}
```

For initial pairing, `client_id` and `request_id` contain the same fresh ULID.
After pairing, the client retains that `client_id` and generates a fresh
`request_id` for each exchange. Request IDs are globally unique values, not
sequence numbers. A client must not intentionally reuse one for another
exchange.

The client generates each request ID immediately before it starts the
corresponding exchange. If the relay rejects a previously unseen request ID as
inadmissible, the client does not retry that operation with the rejected ID.
Retries of an admitted exchange continue to use its original ID.

## Client token

Before initial pairing, the client generates a 32-byte random `client_token`
with a cryptographically secure random-number generator. It encodes the token
as unpadded base64url and sends it only in an HTTP authorization header:

```http
Authorization: Bearer CLIENT_TOKEN
```

The token authenticates the client to the relay. It is independent of the
end-to-end client PSK and is never included in an application payload. The
client uses the same token for every reconnect of the pairing attempt and,
after enrollment, for every authenticated connection belonging to that
`client_id`. A client token does not authorize another client ID.

## WebSocket connections

The relay URL uses `wss`. Clients perform normal WebSocket upgrade over TLS,
including certificate-chain and hostname validation. Version 1 does not
negotiate a WebSocket subprotocol.

Each upgrade request includes a `User-Agent` field. Its value uses HTTP product
tokens, with the embedding application before the Agentknock library:

```http
User-Agent: example-app/1.2.3 agentknock/0.2.0
```

If the application and library products are identical, the client sends that
product once. If the application name or version is not a valid product token,
the client sends only the Agentknock library product. The field does not include
platform, architecture, client identity, or other host-specific information.
The relay treats it as diagnostic metadata, not authentication or a protocol
capability declaration.

### Initial-pairing connection

A new client connects to:

```text
GET /v1/address/{address_id}/request/{request_id}
```

The client sends its new client token as a bearer token. Every frame on this
connection uses `client_id = request_id`.

The first successful admission fixes the address ID, client ID, client token,
and resolved device for the pairing attempt. Reconnecting with the same
address ID, request ID, and token resumes that attempt. Another address or
token cannot replace the admitted values.

An unsuccessful upgrade does not admit the attempt. An admitted attempt
is resumed with the same identifiers and token. If the relay no longer has
the attempt, the upgrade fails or a subsequent `resume` returns `inactive`.

### Authenticated client connection

An enrolled client connects to:

```text
GET /v1/device/{device_id}/client/{client_id}
```

The bearer token must match the active client record selected by the URL. A
`client_id` inside a frame must match the authenticated connection identity;
the frame cannot select another client.

An active client can create and resume exchanges. A suspended or revoked
client cannot connect. The relay can also suspend service independently of the
device-controlled client state.

### Upgrade failures

An unsuccessful upgrade returns a JSON object with an `error` and `message`
string:

```json
{
  "error": "UNAUTHORIZED",
  "message": "Authentication failed."
}
```

Clients use the HTTP status code as the primary classification. Common status
codes are:

| Status | Meaning |
| --- | --- |
| `400 Bad Request` | The identifier or upgrade request is malformed or inadmissible. |
| `401 Unauthorized` | The bearer token is missing or invalid. |
| `403 Forbidden` | The client exists but is not active, or service policy denies it. |
| `404 Not Found` | The address, device, or client route is unavailable. |
| `409 Conflict` | An identifier is already bound incompatibly. |
| `426 Upgrade Required` | The request is not a WebSocket upgrade. |
| `429 Too Many Requests` | A temporary connection or admission limit was exceeded. |
| `500 Internal Server Error` | The relay failed to process the upgrade. |

A retryable HTTP error can also contain `retryable: true` and a
`retry_after_ms` integer. An HTTP `429` response can include a standard
`Retry-After` header. A client honors the supplied delay before retrying.

## Frame encoding

Each application frame is a UTF-8 JSON object with a string `type` member.
Binary WebSocket frames are invalid. A sender includes every required member.
A receiver ignores unknown object members but rejects an unknown frame type,
a missing required member, a member of the wrong type, or a frame that the
peer role cannot send.

The maximum encoded WebSocket frame size is 256 KiB. The `payload` member can
contain any JSON value, including `null`; its presence is significant.

Every exchange-bearing frame contains `client_id` and `request_id`. The client
rejects a received frame whose identifier does not match the active exchange.

## Message kinds and client role

Version 1 defines three message kinds:

| Kind | Client action | Device action visible to the client |
| --- | --- | --- |
| `request` | Publish. | Accept for delivery. |
| `response` | Accept and acknowledge. | Publish one response. |
| `completion` | Publish. | Accept for best-effort delivery. |

The normal order is request, response, completion. A client can publish a
completion without receiving a response when it aborts an exchange. If it
receives a response, it acknowledges that response before publishing the
completion.

The complete identity of an application message is
`(client_id, request_id, kind)`. The first payload accepted for that identity
wins. The relay accepts later duplicates without comparing their payloads and
does not replace the stored value.

## Message frame

The client publishes a request or completion with a `message` frame:

```json
{
  "type": "message",
  "client_id": "01K2ENXDTW1P3XAR4J7V7C9D0H",
  "request_id": "01K2EP16NWNAGJYF8J1Q2V6P3X",
  "kind": "request",
  "payload": {
    "opaque": "application value"
  }
}
```

The relay delivers a device response to the client with the same frame shape
and `kind: "response"`. Application payloads are defined by the client-device
protocol.

The origin retains the fixed frame values until the relay acknowledges them.
If the connection fails first, the origin sends those values again after
reconnecting. The JSON byte serialization does not need to be identical.

## Acknowledgement frame

The relay acknowledges an accepted client message with:

```json
{
  "type": "ack",
  "client_id": "01K2ENXDTW1P3XAR4J7V7C9D0H",
  "request_id": "01K2EP16NWNAGJYF8J1Q2V6P3X",
  "kind": "request"
}
```

An `ack` means that the relay accepted responsibility for the message. After
receiving it, the client no longer needs to retain the payload for relay
retransmission, including if the connection then closes.

The client sends the same frame with `kind: "response"` after it accepts a
response into its live exchange state. Application rejection does not delay
this transport acknowledgement: repeating the same payload cannot repair an
invalid application message.

Acknowledgements are not themselves acknowledged. A repeated acknowledgement
is harmless and cannot revive an inactive message.

The relay acknowledges every accepted completion. For an unknown or inactive
exchange, it can discard the completion while acknowledging its terminal
intent. Completion acknowledgement therefore does not guarantee device
delivery.

## Receipt frame

After the eventual recipient accepts a client-originated message, the relay
sends:

```json
{
  "type": "receipt",
  "client_id": "01K2ENXDTW1P3XAR4J7V7C9D0H",
  "request_id": "01K2EP16NWNAGJYF8J1Q2V6P3X",
  "kind": "request"
}
```

The relay sends an origin's `ack` before its `receipt` on one socket. Receipts
are informational and are not acknowledged. A later state snapshot provides
the same delivery fact while relay state is retained.

A request receipt means that the device accepted responsibility for the
request. It does not mean that the device produced a response. A client does
not need to wait for a completion receipt and can stop after receiving the
completion acknowledgement.

## Resume frame

After reconnecting, a client associates its new socket with one accepted
exchange by sending:

```json
{
  "type": "resume",
  "client_id": "01K2ENXDTW1P3XAR4J7V7C9D0H",
  "request_id": "01K2EP16NWNAGJYF8J1Q2V6P3X"
}
```

The client sends `resume` only after the relay has acknowledged its request.
If it did not receive that acknowledgement, it resends the retained request
instead.

Authentication alone does not associate a new socket with every exchange for
that client. The client resumes each live exchange separately.

## State frame

The relay answers a valid resume with one authoritative snapshot:

```json
{
  "type": "state",
  "client_id": "01K2ENXDTW1P3XAR4J7V7C9D0H",
  "request_id": "01K2EP16NWNAGJYF8J1Q2V6P3X",
  "exchange": "open",
  "request": "delivered",
  "response": "accepted",
  "completion": "absent"
}
```

`exchange` has one of the following values:

| State | Meaning |
| --- | --- |
| `open` | The request is accepted; a response or completion can still arrive. |
| `closing` | A completion is accepted; only remaining delivery can continue. |
| `settled` | Completion delivery finished and only recovery state remains. |
| `expired` | A deadline or client-state transition ended delivery. |

Each message state has one of the following values:

| State | Meaning |
| --- | --- |
| `absent` | The relay has not accepted the message. |
| `accepted` | The relay accepted the message, but the recipient has not accepted it. |
| `delivered` | The recipient accepted the message. |
| `discarded` | The relay will not deliver the message. |

The valid state combinations are:

| Exchange | Request | Response | Completion |
| --- | --- | --- | --- |
| `open` | `accepted` or `delivered` | `absent`, `accepted`, or `delivered` | `absent` |
| `closing` | `accepted` or `delivered` | `absent`, `delivered`, or `discarded` | `accepted` |
| `settled` | `delivered` | `absent`, `delivered`, or `discarded` | `delivered` |
| `expired` | `delivered` or `discarded` | `absent`, `delivered`, or `discarded` | `absent` or `discarded` |

Any response state other than `absent` implies that the request is
`delivered`.

The client reconciles its local state from the snapshot. The relay then sends
any pending response for that client. A `state` frame is not acknowledged; the
client resumes again if the snapshot is lost.

## Inactive frame

If an exchange has no live or replayable state, the relay sends:

```json
{
  "type": "inactive",
  "client_id": "01K2ENXDTW1P3XAR4J7V7C9D0H",
  "request_id": "01K2EP16NWNAGJYF8J1Q2V6P3X"
}
```

An `inactive` reply to a failed message can also contain `kind`. With `kind`,
only that message can no longer be accepted or delivered. Without `kind`, the
whole exchange is inactive.

The client stops the affected retry. An `inactive` frame does not confirm
delivery and is not acknowledged. A message already in flight can arrive
after an inactive or terminal state; the client discards it.

## Error frame

The relay reports a frame or admission error with:

```json
{
  "type": "error",
  "client_id": "01K2ENXDTW1P3XAR4J7V7C9D0H",
  "request_id": "01K2EP16NWNAGJYF8J1Q2V6P3X",
  "kind": "request",
  "error": "RATE_LIMITED",
  "message": "The client created too many new exchanges.",
  "retryable": true,
  "retry_after_ms": 60000
}
```

`error`, `message`, and `retryable` are required. The relay includes
`client_id`, `request_id`, and `kind` when they are relevant and known. A
retryable error can include `retry_after_ms`.

The error code set is extensible. Version 1 codes include:

| Code | Retry behavior |
| --- | --- |
| `INVALID_FRAME` | Do not retry the frame unchanged. |
| `INVALID_REQUEST_ID` | Do not retry the operation with that request ID. |
| `REQUEST_ID_CONFLICT` | Do not retry the operation with that request ID. |
| `CLIENT_INACTIVE` | Stop; the pairing is not active at the relay. |
| `RATE_LIMITED` | Retry after the supplied delay. |
| `CAPACITY_EXCEEDED` | Retry after the supplied delay. |
| `INTERNAL_ERROR` | Reconnect and retry according to local policy. |

When `retryable` is false, the client stops the affected exchange. When it is
true, the client closes or abandons the current socket, waits at least the
specified delay, and follows the reconnect rules. A valid nonretryable frame
does not become retryable because its code is unknown.

## Reconnection and recovery

The client retains only the state needed for its live process. It uses the
following recovery rules after a connection failure:

| Local state | Retained payload | Reconnect action |
| --- | --- | --- |
| No relay acknowledgement | Yes | Resend the fixed `message` frame values. |
| Relay acknowledgement, no receipt | No | Send `resume` if onward delivery still matters. |
| Receipt | No | Continue to wait for the next application message, or finish. |
| Inactive | No | Stop the affected operation. |

A healthy WebSocket does not need application-level polling. The protocol
does not impose a response deadline while the connection remains healthy. The
client waits for a response or terminal relay frame unless the user or
embedding application cancels the operation. A client can use standard
WebSocket Ping and Pong control frames to detect a dead connection.

## Observable exchange behavior

The client can rely on the following behavior:

- The first accepted request receives an acknowledgement and opens the
  exchange.
- A duplicate request receives an acknowledgement. If the device already
  accepted it, the client also observes a receipt or delivered state.
- A device response implies request delivery. The client accepts at most one
  response payload and acknowledges every duplicate of that accepted message.
- An accepted completion moves an open exchange to closing and discards an
  undelivered response.
- A completion for an unknown or inactive exchange is acknowledged and
  discarded. A reordered request for that exchange does not create a new
  operation.
- The client can stop after the relay acknowledges its completion. It does not
  need to wait for device delivery.

If request and completion are both pending for device delivery, the relay
preserves request-before-completion order.

## Expiration

This protocol does not define retention periods. When retained recovery state
is available, `resume` returns a `state` frame. When the exchange is no longer
available, it returns `inactive`. A client does not infer either condition from
elapsed time.

## WebSocket closure

The relay uses standard WebSocket closure codes for common protocol failures:

| Code | Meaning |
| --- | --- |
| `1008` | Invalid or peer-forbidden frame. |
| `1009` | Frame exceeds 256 KiB. |
| `1011` | Transient relay failure. |
| `1013` | Temporary rate limit. |
| `4002` | The client was suspended. |
| `4003` | The client was revoked. |
| `4004` | Relay service for the client or device was suspended. |

The client does not reconnect immediately after a deliberate inactive or
service-policy closure. An unexpected closure follows the recovery table based
on whether the relay acknowledged the message in progress.

## Security properties

The client token authenticates the client to the relay. It does not make the
relay a trusted endpoint for application data. A compromised relay can read
identifiers, tokens presented to it, timing, message sizes, and traffic
relationships. It can delay, reorder, replay, suppress, or fabricate transport
traffic and unprotected error reports.

End-to-end security comes from the client-device cryptosystem. A client must
not treat an `ack`, `receipt`, `state`, or relay `error` frame as proof of a
device decision. Only an authenticated client-device response establishes the
result of an invocation, another paired operation, or a pairing-state change.

## References

- [Agentknock v1 client-device protocol]client-device-protocol.md
- [Agentknock v1 cryptosystem]cryptosystem.md
- [RFC 6455: The WebSocket Protocol]https://www.rfc-editor.org/rfc/rfc6455.html
- [RFC 9110: HTTP Semantics]https://www.rfc-editor.org/rfc/rfc9110.html