Source: https://harnsy.dev/docs/h2a-v1

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

# 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://&lt;host&gt;[: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.

> Warning
> 
> A proxy of your own that ends TLS in front of the gateway does see them: the token and every text pass it in clear.

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

| 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`: an `address` of `welcome`: `lead` or `role:&lt;role id&gt;`. 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.

> Warning
> 
> If your service passes other people’s messages (a ticket, a customer’s text), that text goes to your agents and to their AI provider. You are responsible for having the right to pass it, and for telling the people concerned.

## 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 &gt; 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.

| 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 `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

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