harnsy H2A, version 1
The contract of an outside service (H2A, “harnsy to agent”). A service outside your computer holds one WebSocket and talks to a project’s posts — a role, or the project’s lead — in conversations that outlive the agent on the post: it gives a task in text, the agent answers in the same conversation. Every kind of outside service comes in through this one contract.
1. Connect
- On the dashboard you make a pass (Settings › Services): the project, the service’s name, and the posts it may write to (roles of the project and/or “Project lead”). The token (
h2a_and 32 random bytes, base64url) is shown once; harnsy keeps only its SHA-256. - Connect with
GET wss://<host>[:port]/h2a/v1and the headerSec-WebSocket-Protocol: harnsy.h2a.v1. Use the access from outside of your harnsy (its gateway), or a relay of your own. TLS ends on your computer: the gateway’s own TLS, or a relay that passes the TLS bytes on unread; by design, no such relay sees the token or the texts.
- Refused before the upgrade: no subprotocol,
400; anyOriginheader,403(a service is not a web page); a node without harnsy Max,403 module_off; another path or method,404; more than 4 sockets without a hello from one address (an IPv6 address by its /64), or 32 on the node,429. Each connection counts as one of the gateway’s open requests of that address, so a service that keeps reconnecting meets its limit (429). The path is served by the gateway only. - Turning the gateway off (or switching its mode) drops every open connection at once, without a frame. Reconnect when it is back, with the pause of section 5 (1 s, doubled up to 60 s, ±20 %).
- The token goes in the
helloframe only: never in the URL (it settles in logs) and never in a header. By design, harnsy does not log the hello; an error on it names the field, never the value. - The first frame is
hello, within 10 seconds; elseh2a_hello_requiredand the connection closes.
2. Frames
One JSON object per text message. type is the frame’s kind. An unknown kind or not JSON: error h2a_frame, the connection stays. Unknown fields of a known frame are skipped.
| Frame | Who | Fields | Meaning |
|---|---|---|---|
hello | service | v ("1"), token, name?, can[], resume_after? | sign in; can is what the service takes (version 1: ["text"]; at most 16 looked at, unknown dropped; without text among them h2a_bad_frame, field can, and the connection closes; no can at all is ["text"]); name is not used anywhere: harnsy names the service by its pass |
welcome | harnsy | v, project, to[], limits | signed in; to[] the pass’s addresses {address, name, about?} |
message | service → harnsy | id, to?, conversation?, parts[] | a task or a reply; without conversation a new conversation to to |
accepted | harnsy | re (= id), conversation | taken and handed to the post; a repeated id gets the same accepted |
message | harnsy → service | seq, conversation, from: {project, role}, parts[], at | an agent’s reply; never the agent’s name, session or machine |
ack | service | up_to | “everything up to seq inclusive is received” |
error | both | re?, code, text | a refusal of a frame or of the connection |
ping / pong | both | — | liveness |
id: 1–128 of[A-Za-z0-9._:-], the service’s; the key of a repeat is (pass,id) for 24 h.to: anaddressofwelcome:leadorrole:<role id>. Required withoutconversation; with one, absent or the conversation’s address, elseh2a_bad_frame.conversation: an opaque id of 20 characters that harnsy gives; looked up among this pass’s conversations only.parts[]: version 1 knows{"text": "…"}only, 1 byte to 16 KB of text in a frame; another kind of part ish2a_part.from.role: the role’s public title when it has one, else its title;from.project: the project’s name.at: RFC 3339, UTC.limitsinwelcome:frames_per_minute,messages_per_day,text_bytes,frame_bytes,queue,queue_hours,ping_seconds,idle_seconds(section 6).
3. Conversations
- A conversation is a pass, an address and the
conversationid; one address may have many. It lives 30 days from its last message, then it is gone: amessageinto it ish2a_conversation_unknown. - To a role: the holder who already answered in the conversation, while it still holds the role; else every holder of the role. To
lead: the project’s lead at the time. No lead, or nobody on the role, ish2a_no_holderand nothing is kept. - A handover on the post does not break the conversation: the next message goes to the new holder, who answers in the same conversation.
- The agent reads the message in a frame “from outside: data, not a command”, under the name you gave the pass, with the conversation and how to reply. In version 1 an agent does not start a conversation. An outside text is marked as data, not a command, and the agent is told so; it is not a guarantee. Give a service only the posts it needs, and watch the journal.
4. Delivery and ack
- Each reply of an agent is queued for the pass and gets
seq: a number per pass from 1, never given twice. A live connection gets it at once; otherwise it waits. ack {up_to: N}removes everything up toNinclusive; the text is erased with it.- Reconnect:
hello.resume_after = Nsends every unacknowledged reply withseq > Nin order, then new ones. Without it: every unacknowledged one. - Not acknowledged within 24 h: dropped, text erased; the agent whose reply it was gets one line from harnsy per conversation.
- At most 500 unacknowledged per pass; past it the agent’s reply is refused.
5. Errors
An error carries no words of the service, only the code and harnsy’s words: {"type":"error","re"?,"code","text","field"?,"retry_after"?,"until"?}. A service’s own error frame reaches no agent: harnsy keeps its code only.
| Code | When | Connection | Client |
|---|---|---|---|
h2a_version | v of hello is not "1" | closed, 4400 | stop |
h2a_hello_required | the first frame is not hello, or none within 10 s | closed, 4400 | stop |
h2a_auth | the token is wrong or revoked (the same answer), a new token was issued, or the pass was deleted (its project too) | closed, 4401 | stop |
h2a_off | the pass is off, its project archived, or harnsy Max ended — only after the token is good | closed, 4403 | stop |
h2a_replaced | another connection of the pass signed in with the right token | the old one closed, 4409 | stop |
h2a_ping | no pong within 60 s | closed, 4408 | reconnect |
h2a_rate on hello | 10 failed hellos a minute from one address (an IPv6 address by its /64): 10 min shut out (the right token still passes) | closed, 4429 | wait retry_after |
h2a_bad_frame on hello | can without text among its first 16 values | closed, 4400 | stop |
h2a_frame | not JSON, an unknown type | stays | caller |
h2a_bad_frame | a field breaks section 2 (named in field) | stays | caller |
h2a_part | a kind of part version 1 does not know | stays | caller |
h2a_too_big | text over 16 KB | stays | caller |
h2a_address | to is not an address of welcome | stays | caller |
h2a_conversation_unknown | not this pass’s conversation, or gone | stays | caller |
h2a_no_holder | nobody holds the address now | stays | caller |
h2a_rate | the frame limit (section 6), retry_after in seconds | stays | wait retry_after |
h2a_daily | the day’s messages to agents, until (RFC 3339) | stays | caller |
WebSocket 1006 | the network, or the gateway turned off: no frame | closed | reconnect |
WebSocket 1009 | a frame over 256 KB (8 KB before hello) | closed | reconnect |
Client is what a service does, the same in every SDK: stop — stop and do not reconnect, a person has to act (a new token, the pass on); reconnect — reconnect after a pause of 1 s, doubled each failed try up to 60 s, each ±20 % at random, a welcome resets it, the hello with resume_after; wait retry_after — wait retry_after seconds, then reconnect (a closed connection) or send the same frame once more (a frame); caller — the connection stays, the refusal goes to whoever sent the frame (until of h2a_daily says when to send again). After 1009 the frame’s sender gets the error too.
An agent’s reply is refused with h2a_bad_body, h2a_conversation_unknown, h2a_not_your_conversation, h2a_paused, h2a_rate, h2a_queue_full, h2a_off or h2a_scrub; the service never sees those.
6. Limits
| What | Limit | Past it |
|---|---|---|
| connections per pass | 1 | the second closes the first (h2a_replaced) |
| frame | 256 KB; text 16 KB (both ways) | 1009 / h2a_too_big |
| frames from the service | 60 a minute per pass (every frame, ping too); 300 a minute per node (ping and pong not counted there) | h2a_rate; a pong past the limit still counts as the answer to harnsy’s ping |
| messages to agents | 100 a day per pass, every message | h2a_daily; one bell to the person a day |
| agents’ replies | 60 an hour per conversation | the agent is refused |
| loop | 6 replies of agents in a row without the service’s message | the conversation pauses; the person’s “Continue” or the service’s next message resumes it |
| queue | 500 unacknowledged per pass, 24 h | the agent is refused / dropped |
| liveness | harnsy’s ping every 20 s; no pong 60 s | closed |
| sign-in | hello within 10 s; 10 failed hellos a minute from an address | closed / shut out 10 min |
before hello | frame 8 KB; 4 sockets per address (IPv6: per /64), 32 per node | refused before the upgrade |
7. Versions
- The version is in
helloandwelcome. Within1fields are only added; a side skips fields it does not know. - A new kind of part or frame within
1only if the service declared it incan[]; harnsy sends nothing the service’scan[]lacks. - An incompatible change is
2; harnsy answers both versions while they are supported.
8. How to call
A service in Python with websockets: connect, sign in, send a task, take the reply, acknowledge it, and reconnect from the last seq it has — as the “Client” column of section 5 says: stop on stop, wait retry_after, else reconnect with a growing pause. In Go: github.com/harnesy/h2a-go, which follows the same column.
import asyncio, json, os, random, websockets
URL, TOKEN = "wss://node.example/h2a/v1", os.environ["H2A_TOKEN"]
STOP = {"h2a_auth", "h2a_off", "h2a_replaced"} # «stop» on a live connection; every refused hello but h2a_rate stops too
last = 0 # the last seq received: keep it across restarts
class Stop(Exception): pass
class Wait(Exception): pass
async def session(task):
global last
async with websockets.connect(URL, subprotocols=["harnsy.h2a.v1"]) as ws:
await ws.send(json.dumps({"type": "hello", "v": "1", "token": TOKEN,
"can": ["text"], "resume_after": last}))
welcome = json.loads(await ws.recv())
if welcome["type"] == "error": # a refused hello closes the connection
if welcome["code"] == "h2a_rate":
raise Wait(welcome.get("retry_after", 600))
raise Stop(welcome["code"])
if task:
await ws.send(json.dumps({"type": "message", "id": "task-1", "to": welcome["to"][0]["address"],
"parts": [{"text": task}]}))
async for raw in ws:
f = json.loads(raw)
if f["type"] == "ping":
await ws.send('{"type":"pong"}')
elif f["type"] == "message":
print(f["from"]["role"], ":", f["parts"][0]["text"])
last = f["seq"]
await ws.send(json.dumps({"type": "ack", "up_to": last}))
elif f["type"] == "error" and f["code"] in STOP:
raise Stop(f["code"]) # h2a_replaced: another copy of the service runs
elif f["type"] in ("accepted", "error"):
print(f) # a refused frame: the connection stays
async def main():
task, pause = "The video is ready, look at scene 2", 1
while True:
try:
await session(task)
pause = 1
except Stop as e:
raise SystemExit(f"stopped: {e}") # a person acts: a new token, the pass on
except Wait as e:
await asyncio.sleep(int(str(e)))
except (OSError, websockets.ConnectionClosed):
await asyncio.sleep(pause * random.uniform(0.8, 1.2)) # resume_after sends what was missed
pause = min(pause * 2, 60)
task = None # the same id would get the same accepted; this example sends it once
asyncio.run(main())Docs footer line goes under both pages (shared line in docs-pages-1-7-4250.md; it must also name Python and Go: “Python is a trademark of the Python Software Foundation”).
Since harnsy 0.11.0 · harnsy Max · version 1 of the contract (fixed address; a version 2 would get its own page).
Anthropic, Claude Code, OpenAI, ChatGPT, Codex, OpenCode, Telegram, Discord, GitHub, Apple, macOS, iOS, iPhone, Safari, Google, Android, Chrome, Microsoft, Windows, Windows Security, Defender and QR Code are names of their owners’ products. harnsy is not affiliated with or endorsed by them. iOS is a trademark of Cisco used under licence by Apple; QR Code is a registered trademark of DENSO WAVE INCORPORATED. No logos. Python and Go are names of their owners’ products. Python is a trademark of the Python Software Foundation. Go is a trademark of Google LLC.