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)#
- <input type="checkbox" disabled > Parse the three forms:
airc://authority/path,//authority/path, and localns/name/name. - <input type="checkbox" disabled > Authority: DNS name or IP literal (
[2001:db8::1],192.0.2.1), optional:port. Lower-case it. Drop the default port. - <input type="checkbox" disabled > Segments:
[A-Za-z0-9_-]{1,63}, no leading or trailing-, no.. Compare case-insensitively, emit lower case. - <input type="checkbox" disabled > Trailing
/marks a namespace, not an endpoint.//h/aand//h/a/are different addresses. - <input type="checkbox" disabled >
#fragment: keep it, pass it through, ignore it for comparison and routing. - <input type="checkbox" disabled > Segments starting with
_are reserved (_resolver,_postmaster,_dir). - <input type="checkbox" disabled > Whole address ≤ 1024 octets.
- <input type="checkbox" disabled > 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 (§3)#
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.