harnsy

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/v1 and the header Sec-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; any Origin header, 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 hello frame 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; else h2a_hello_required and 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.

FrameWhoFieldsMeaning
helloservicev ("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
welcomeharnsyv, project, to[], limitssigned in; to[] the pass’s addresses {address, name, about?}
messageservice → harnsyid, to?, conversation?, parts[]a task or a reply; without conversation a new conversation to to
acceptedharnsyre (= id), conversationtaken and handed to the post; a repeated id gets the same accepted
messageharnsy → serviceseq, conversation, from: {project, role}, parts[], atan agent’s reply; never the agent’s name, session or machine
ackserviceup_to“everything up to seq inclusive is received”
errorbothre?, code, texta refusal of a frame or of the connection
ping / pongboth—liveness
  • id: 1–128 of [A-Za-z0-9._:-], the service’s; the key of a repeat is (pass, id) for 24 h.
  • to: an address of welcome: lead or role:<role id>. Required without conversation; with one, absent or the conversation’s address, else h2a_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 is h2a_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.
  • limits in welcome: 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 conversation id; one address may have many. It lives 30 days from its last message, then it is gone: a message into it is h2a_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, is h2a_no_holder and 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 to N inclusive; the text is erased with it.
  • Reconnect: hello.resume_after = N sends every unacknowledged reply with seq > N in 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.

CodeWhenConnectionClient
h2a_versionv of hello is not "1"closed, 4400stop
h2a_hello_requiredthe first frame is not hello, or none within 10 sclosed, 4400stop
h2a_auththe token is wrong or revoked (the same answer), a new token was issued, or the pass was deleted (its project too)closed, 4401stop
h2a_offthe pass is off, its project archived, or harnsy Max ended — only after the token is goodclosed, 4403stop
h2a_replacedanother connection of the pass signed in with the right tokenthe old one closed, 4409stop
h2a_pingno pong within 60 sclosed, 4408reconnect
h2a_rate on hello10 failed hellos a minute from one address (an IPv6 address by its /64): 10 min shut out (the right token still passes)closed, 4429wait retry_after
h2a_bad_frame on hellocan without text among its first 16 valuesclosed, 4400stop
h2a_framenot JSON, an unknown typestayscaller
h2a_bad_framea field breaks section 2 (named in field)stayscaller
h2a_parta kind of part version 1 does not knowstayscaller
h2a_too_bigtext over 16 KBstayscaller
h2a_addressto is not an address of welcomestayscaller
h2a_conversation_unknownnot this pass’s conversation, or gonestayscaller
h2a_no_holdernobody holds the address nowstayscaller
h2a_ratethe frame limit (section 6), retry_after in secondsstayswait retry_after
h2a_dailythe day’s messages to agents, until (RFC 3339)stayscaller
WebSocket 1006the network, or the gateway turned off: no frameclosedreconnect
WebSocket 1009a frame over 256 KB (8 KB before hello)closedreconnect

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

WhatLimitPast it
connections per pass1the second closes the first (h2a_replaced)
frame256 KB; text 16 KB (both ways)1009 / h2a_too_big
frames from the service60 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 agents100 a day per pass, every messageh2a_daily; one bell to the person a day
agents’ replies60 an hour per conversationthe agent is refused
loop6 replies of agents in a row without the service’s messagethe conversation pauses; the person’s “Continue” or the service’s next message resumes it
queue500 unacknowledged per pass, 24 hthe agent is refused / dropped
livenessharnsy’s ping every 20 s; no pong 60 sclosed
sign-inhello within 10 s; 10 failed hellos a minute from an addressclosed / shut out 10 min
before helloframe 8 KB; 4 sockets per address (IPv6: per /64), 32 per noderefused before the upgrade

7. Versions

  • The version is in hello and welcome. Within 1 fields are only added; a side skips fields it does not know.
  • A new kind of part or frame within 1 only if the service declared it in can[]; harnsy sends nothing the service’s can[] 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.

Python
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.