# Pulsar node protocol (v0.4) — build your own agent An agent is any program that pairs once, then loops: heartbeat → next → do the task → submit. All calls are `POST https://astrina.io/api/v1/node/` with `Content-Type: application/json`. After pairing, send the device secret as header `X-Node-Key: pn_...`. ## 1. register (once, exchanges a pairing code for a secret) ``` POST /api/v1/node/register { "pair_code":"ABC123XYZ0", "kind":"cli", "label":"my-agent", "consent":1, "ver":"0.3.0", "os":"Linux", "caps":{"uptime":1,"perf":1,"ssl":1,"content":1,"keyword":1,"dns":1,"api":1, "infer":1,"mixed":1,"redirect":1} } → { "ok":true, "device_id":42, "secret":"pn_…" } # secret shown ONCE ``` `consent:1` is REQUIRED (the contributor accepted /pulsar/terms). `caps` declares which task kinds you can run — you only ever get those. ## 2. heartbeat (liveness + version gate) ``` POST /api/v1/node/heartbeat { "ver":"0.3.0", "os":"Linux" } → { "ok":true } # or { "pause":true } or { "min_version":"0.3.0" } ``` ## 3. next (claim work) ``` POST /api/v1/node/next { "want":5, "pubkey":"<64-hex, optional>" } → { "ok":true, "tasks":[ { "assign_id":123, "nonce":"<32-hex>", "kind":"uptime.probe", "payload":{"url":"https://…"} } ], "next_poll":15 } # honor next_poll (seconds) as your poll interval `nonce` (v0.4) is minted per assignment and is unpredictable — see signing below. `pubkey` here is adopted ONCE, and only if the server has no key for this device yet: a device enrolled before signing existed can start signing without re-pairing. Replacing an existing key still requires `rotate-key`, where the new key is signed by the old one. ``` ## 4. submit (return results) ``` POST /api/v1/node/submit { "results":[ { "assign_id":123, "result":{ "ok":true, "code":200, "ttfb_ms":80, "total_ms":140 }, "ms":150 } ] } → { "ok":true, "accepted":[123] } ``` ## Task kinds and the result shape each must return - `uptime.probe` / `perf.measure` → `{ok,code,ttfb_ms,total_ms,bytes,tls_days}` - `ssl.audit` → `{ok,tls_days,hsts,csp,xfo}` (https only) - `content.watch` → `{ok,code,hash}` (16-hex sha256 of normalized text; may add `body` when hash≠payload.known_hash) - `keyword.check` → `{ok,found}` · `api.check` → `{ok,pass}` · `dns.check` → `{ok,ips:[…]}` - `link.audit` → `{ok,code,found,follow,visible,indexable}` — payload `{url,target}`: fetch `url`, report whether an anchor to domain `target` is present, carries no nofollow/sponsored/ugc rel, is not hidden (inline style / hidden attr / an unclosed hidden container), and whether the page is indexable (no meta-robots/X-Robots-Tag noindex). Booleans only — the quorum compares them byte-for-byte. - `port.check` → `{ok,open}` · `infer.embed` → `{ok,sim,lang}` - `compress.audit` → `{ok,code,raw_bytes,best_bytes,gzip,br,zstd,savings_pct}` - `ipv6.parity` → `{ok,v4,v6}` · `mixed.scan` → `{ok,code,mixed}` · `redirect.chain` → `{ok,hops,loop}` - `robots.diff` → `{ok,code,robots}` (16-hex) · `captcha.rate` → `{ok,code,challenged}` - `asset.integrity` → `{ok,code,ext_scripts,protected}` · `cdn.pop` → `{ok,code,cdn,pop}` - `geo.block` → `{ok,code,blocked}` · `dns.dnssec` → `{ok,signed}` · `traceroute.map` → `{ok,hops}` - `journey.check` → `{ok,steps,failed_step}` (payload.steps = [{url,expect},…]) - `cdn.pop` → `{ok,code,cdn,pop}` · `geo.block` → `{ok,code,blocked}` · `dns.dnssec` → `{ok,signed}` - `traceroute.map` → `{ok,hops}` · `app.rank` → `{ok,rank,found}` (payload {track_id,country,chart}) ## Rules every agent MUST follow - Only http(s), only PUBLIC targets. Refuse private/reserved IPs even if asked (client-side SSRF guard). - Send no cookies, no personal data. Identify with your own User-Agent. - Results are cross-checked on several devices (quorum). Faking work loses trust and pay. - Rate limit: 120 requests/min/device → HTTP 429 with `retry_after`. Honor `next_poll`. - Byte-identical normalization matters for content.watch/infer — match the reference agent exactly. ## infer.neural — the signature contract A device that carries a neural embedding runtime reports `{"sig":"<16 lowercase hex>"}`: the 64-bit LSH signature of its embedding of the fetched text. Consensus is tolerance-based (Hamming distance <= 6 bits), because real embeddings are not bit-identical across backends. Every client MUST project onto the same 64 directions, or honest devices will never agree. The basis is fixed and reproducible in ten lines: seed = 0x50554C53 // "PULS", 32-bit next(): // xorshift32, then mapped to [-1, 1) x ^= x << 13; x &= 0xFFFFFFFF x ^= x >> 17 x ^= x << 5; x &= 0xFFFFFFFF return x / 2147483648.0 - 1.0 basis = 64 rows x dim components, filled row-major by next() bit_i = dot(embedding, basis[i]) >= 0 ? 1 : 0 sig = the 64 bits, most significant first, as 16 hex digits Reference: `nt_neural_basis()` / `nt_neural_sig()` in `private/lib/nodetasks.php`. What we measured, and what it does not prove: on real published material, signatures of the SAME material in DIFFERENT languages landed 3, 5 and 12 bits apart, unrelated material 13 and 16. That is a worst case and not the operating case — an `infer.neural` task asks several devices to embed the SAME text, where the only source of difference is backend arithmetic. We have not yet measured that case, because no client ships a neural runtime; when one does, check it against tol = 6 before trusting the number. ## Adding a new kind of check Extending the network, not just joining it: `https://astrina.io/pulsar/setup?dl=newkind` is the five-file checklist (capability, result whitelist, consensus key, generator, staged rollout) and what we ask before merging a contributed kind. ## Sandbox — build your agent before you own a device POST /api/v1/node/sandbox { "do":"next", "session":"your-name" } → task-shaped work with a nonce, exactly like /next POST /api/v1/node/sandbox { "do":"submit", "session":"your-name", "pubkey":"<64-hex>", "results":[ { "assign_id":…, "result":{…}, "sig":"…" } ] } → per result: accepted true/false and WHY No pairing, no key, nothing stored, nothing paid. Assignment ids and nonces are derived from your `session` string, so the same session always sees the same work and two people never collide. The submit side answers what the real server would: which fields it would drop from your result, whether your signature verifies (and against which message form), and whether the assign_id was ever issued. Use it to get the shapes right, then pair a real device. ## Optional result signing (Ed25519) Send `pubkey` (64-hex Ed25519 public key) at register, or later with `next` (see above). Then sign each result with the private key, hex, per result: v0.4 sig = ed25519(assign_id + ":" + nonce + ":" + json(result with keys sorted)) v0.3 sig = ed25519(assign_id + ":" + json(result with keys sorted)) # still accepted Use the v0.4 form whenever the task carried a `nonce`. Why it changed: assign ids run in sequence, so the v0.3 message could be signed for an id before it was handed out — the signature proved authorship but not that the work followed the assignment. The nonce cannot be guessed in advance, so a valid v0.4 signature can only have been made after this assignment was issued. The server verifies and drops a replica whose signature does not match. No key → still accepted (quorum + bearer + TLS already protect you). The reference agent does this automatically. The reference implementation (one PHP file) is the source of truth: `https://astrina.io/pulsar/setup?dl=agent`.