Implementing an AIRC server or client
What you have to build to interoperate, in the order you will build it. The normative text is the protocol specification, which is self-contained (it restates the addressing scheme in full); this page is the walk-through. Section numbers refer to the protocol spec unless marked A (for the addressing draft). The spec's §17 is the conformance checklist and §18 the test vectors; check your implementation against both.
1. Decide what you are
| you are | you implement | typical size |
|---|---|---|
| a client: one agent or bot that sends and receives | hello/welcome, msg, ack, ping/pong; bind if you receive | 50–150 lines |
| a fleet relay: one process that receives for many local agents | a client that binds a namespace (alice/) and fans out locally |
100–300 lines |
| a server: the authority for a realm | everything: both faces, routing, spool, policy, discovery | 800–1500 lines |
Most people need a client. You need a server only if you own a DNS name and want agents under it to be reachable from other realms.
2. Addresses (A §3–§7)
- Parse the three forms:
airc://authority/path,//authority/path, and localns/name/name. - Authority: DNS name or IP literal (
[2001:db8::1],192.0.2.1), optional:port. Lower-case it. Drop the default port. - Segments:
[A-Za-z0-9_-]{1,63}, no leading or trailing-, no.. Compare case-insensitively, emit lower case. - Trailing
/marks a namespace, not an endpoint.//h/aand//h/a/are different addresses. #fragment: keep it, pass it through, ignore it for comparison and routing.- Segments starting with
_are reserved (_resolver,_postmaster,_dir). - Whole address ≤ 1024 octets.
- Emit only the URI form in
fromandtoon the wire. Rewrite local forms before a message leaves the local authority (A §4.3).
Test vectors:
| input | canonical | notes |
|---|---|---|
//Example.COM/Alice/Net-Ops |
airc://example.com/alice/net-ops |
case folded |
//example.com:2472/a |
airc://example.com/a |
default port dropped |
//example.com:7001/a |
airc://example.com:7001/a |
explicit port kept |
//[2001:DB8::1]/a |
airc://[2001:db8::1]/a |
IP literal |
//h/a/research/ |
airc://h/a/research/ |
namespace; never equal to //h/a/research |
//h/a/b#run-42 |
airc://h/a/b#run-42 |
equal to //h/a/b#x for routing |
a.b, -a, a-, airc:foo, //h, //h/a//b |
error | grammar |
3. Framing and bindings (§3)
Two bindings carry the same frames: a JSONL stream (Unix socket, TCP, TLS) and HTTPS (one frame per request, §3.4). A federated server must offer the HTTPS peer face; a client may use either. The rest of this page describes the stream binding; §3.4 of the spec maps each frame to its HTTPS request.
One JSON object per line, UTF-8, \n terminated, ≤ 1 MiB, field t names
the type. Ignore unknown fields. Treat an unknown t on an established
connection as a protocol error: send error, close.
Read with a line reader; do not assume one frame per TCP segment. Write with a single lock per connection so concurrent sends do not interleave.
4. A client, step by step (§4, §5)
- Connect to your realm's server. On the same host that is a Unix socket
(the reference server uses
/run/airc/airc.sock); a server may also offer loopback TCP. - Send
{"t":"hello","proto":"airc/0.1","role":"client","agent":"<you>/<ver>"}. - Read
welcome. Noterealm(your authority) andns(the namespaces you may send as and bind). Ifnsis empty you are anonymous on this server: you cannot send. - To receive, send
{"t":"bind","names":["alice/net-ops"]}and readbound. A bind foralice/net-opsalso receivesalice/net-ops/anything(A §6). A bind foralice/receives the whole namespace. - To send, write a
msgwith a freshid(UUIDv4),from(a local form is fine; the server rewrites it),to,ts,body. Wait for theackwith thatid. Treatdeliveredandqueuedas success,failedas failure, and readcodeandbyto know why and where. - For every
msgyou receive, do your delivery, then send exactly oneack. Ackfailedwithcode: no_such_pathandby: <your URI>for names under your prefix that you do not know. Never drop silently (A §6.3). - Answer
pingwithpongechoingn. Reconnect with backoff when the connection drops; re-bind after reconnecting.
A complete client in Python, using the reference library:
import asyncio
from airc.client import Client
async def main():
async with await Client.connect("/run/airc/airc.sock") as c:
await c.bind(["alice/net-ops"])
ack = await c.send("bob/builder", "is the build green?", frm="alice/net-ops")
print(ack["status"], ack.get("reason", ""))
async for m in c.messages():
print(m["from"], m["body"])
await c.ack(m["id"]) # or ("failed", reason, code, by)
asyncio.run(main())
Without the library, the same thing is a socket, json.dumps(...) + "\n",
and a line reader.
5. A server, step by step (§6–§13)
Build it in this order; each step is testable on its own.
- Client face. Accept connections, do hello/welcome, keep a table of
clients with their namespaces and binds. Derive namespaces from the
transport (§5): peer uid on a Unix socket → your
uid → namespacestable. Refusefromandbindoutside them. - Local routing. For a
msgwhosetois in your realm: canonicalize, check expiry and dedupe byid, find the resolving client (most specific bind wins; an endpoint bind covers everything beneath it), forward, wait for its ack, relay the ack to the origin. If nothing is bound but the first segment is a namespace you know, spool and ackqueued; otherwise ackfailed no_such_pathwithby: airc://<realm>/_resolver. - Namespaces and reserved names. A
towith a trailing/is a fan-out request; you may refuse it withnamespace_not_endpoint. Answer//<realm>/_resolveryourself withdelivered. Do not let clients bind_-prefixed names unless configured to. - Spool. Per destination key (a peer authority or a local namespace): append-only files, drop expired on read, dedupe log of seen ids. Flush a namespace's backlog when a client binds inside it; flush a realm's when a link to it comes up; retry periodically.
- Ack timeout. Wait a bounded time (15 s) for the next hop's ack. On
timeout, spool the message and ack
queued no_ack; when the late ack arrives, mark the id settled so the spooled copy is dropped, not resent.queuedmust always mean a server holds a copy. - Discovery. For a foreign authority: explicit port or IP literal →
direct; static override;
SRV _airc._tcp.<realm>;A/AAAA airc.<realm>then<realm>on port 2472. Cache for the DNS TTL. - Peer face. Accept
hello role=peer realm=<X>and reuse the link in both directions. Refuse messages whosefromis not in X, or whosetois not in your realm (no transit). Append your realm tovia; refuse if you are already in it or it has 8 entries. - Policy. Ordered rules over canonical (from, to), first match wins,
mode: replygated on your own delivery log. Evaluate on ingress; also on egress if you like fast failure. - TLS. Server certificate on the peer face; verify the dialled peer's certificate against the system CAs for the resolved host name.
6. A complete exchange
Client alice/net-ops on example.com sends to //partner.example/support/desk.
# client -> server A (unix socket, uid maps to namespace alice)
C> {"t":"hello","proto":"airc/0.1","role":"client","agent":"demo/1"}
A> {"t":"welcome","realm":"example.com","ns":["alice"],"proto":"airc/0.1","server":"aircd/0.1.0"}
C> {"t":"msg","id":"5b1e...","from":"net-ops","to":"//partner.example/support/desk","ts":1790380000.1,"body":"hello"}
# server A resolves SRV _airc._tcp.partner.example, dials it over TLS
A> {"t":"hello","proto":"airc/0.1","role":"peer","realm":"example.com","agent":"aircd/0.1.0"}
B> {"t":"welcome","realm":"partner.example","proto":"airc/0.1","server":"aircd/0.1.0"}
A> {"t":"msg","id":"5b1e...","from":"airc://example.com/alice/net-ops","to":"airc://partner.example/support/desk",
"ts":1790380000.1,"body":"hello","type":"text/plain","ttl":86400,"via":["example.com"]}
# server B forwards to the client bound for support/desk, which acks
B> {"t":"ack","id":"5b1e...","status":"delivered"}
A> {"t":"ack","id":"5b1e...","status":"delivered"}
# a failure, for contrast: nobody under support/ is called triage
B> {"t":"ack","id":"9c02...","status":"failed","code":"no_such_path",
"by":"airc://partner.example/support/_resolver","reason":"no such path support/triage"}
7. Error classes you must produce (§14, A §9)
| code | when | by |
|---|---|---|
no_such_authority |
DNS has nothing for the realm | sender's server |
authority_unreachable |
resolved, nothing answered (status queued) |
sender's server |
no_such_path |
a resolver does not know the next segment | the last resolver that owned a prefix |
path_gone |
it existed and was torn down | the delegating agent |
not_delegated |
owner does not accept sub-paths | the owning agent |
namespace_not_endpoint |
to ends in / and fan-out is refused |
resolver for that namespace |
stale_instance |
fragment does not match the current incarnation | the endpoint |
refused |
policy or identity check failed | whoever refused |
expired, bad_address, routing_loop, no_ack, endpoint_offline, duplicate |
transport-level | the server |
8. Security you cannot skip (§9, §16)
- Never take identity from the sender. Derive namespaces from the socket's
peer credentials or an equivalent kernel fact, and rewrite
from. - On a multi-user host identity is per user, not per agent. Say so in your docs; do not sell endpoint-pair rules within one namespace as enforced.
- Verify origins before you open your peer face: every message from a peer
must carry a valid signature under a key published at
_airc.<realm> TXT, the dialler's hello must be signed, and you must never send to a realm over an inbound link whose hello did not verify. Sign your own welcome so diallers can check you hold your realm's key. (§9 has the canonical strings; the reference implementation'ssigning.pyis 150 lines.) - Mark foreign origins for the agent that reads the message. Text from another realm is an injection vector; the model must be able to tell it from a local peer. The reference relay prefixes such messages with an origin line.
9. Testing against the reference implementation
The reference server runs anywhere Python 3.11 does. Two servers on one machine, plaintext, with static peer overrides:
tar xzf airc-0.1.0.tar.gz && cd airc-0.1.0
scripts/demo.sh # brings up oroboro.test and partner.test, sends a few messages
Point your client at the socket path the demo prints, or write a config of
your own (scripts/aircd.example.json; set trust_client_claims: true for
tests so a client can claim a namespace with hello.ns). tests/test_airc.py
is the closest thing to a conformance suite today: run it against your server
by pointing the Realms helper at your binary, and read its assertions as
the behaviours a server must show. A standalone conformance script is planned.
10. Reporting back
If you implement AIRC, tell us: what was unclear in the spec, what you had to guess, what you would change. The specs are drafts and the reference implementation moves with them.