@nyx5/nyx5 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +50 -7
  2. package/bin/nyx5.js +81 -2
  3. package/demo/edge-local.mjs +41 -0
  4. package/demo/x402-pagar.mjs +64 -0
  5. package/demo/x402-testnet.mjs +80 -0
  6. package/docs/ARQUITECTURA.md +354 -0
  7. package/docs/SPEC.md +414 -15
  8. package/examples/ci/nyx5-ejecutor.yml +82 -0
  9. package/examples/frameworks/README.md +55 -0
  10. package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
  11. package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
  12. package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
  13. package/examples/frameworks/claude-agent-sdk.mjs +48 -0
  14. package/examples/frameworks/crewai_nyx5.py +72 -0
  15. package/examples/frameworks/http.md +117 -0
  16. package/examples/frameworks/langgraph_nyx5.py +90 -0
  17. package/examples/frameworks/nyx5_http.py +255 -0
  18. package/examples/frameworks/openai-agents.mjs +41 -0
  19. package/examples/hola-mundo.mjs +4 -4
  20. package/migrations/0006_puente_remoto.sql +19 -0
  21. package/migrations/0007_indice_reputacion.sql +31 -0
  22. package/migrations/0008_notaria.sql +18 -0
  23. package/migrations/0009_contratos_estado.sql +4 -0
  24. package/package.json +4 -2
  25. package/server.json +2 -2
  26. package/src/correo/acciones.js +216 -0
  27. package/src/correo/adjuntos.js +185 -0
  28. package/src/correo/agente.js +370 -22
  29. package/src/correo/asistente.js +599 -0
  30. package/src/correo/ci.js +262 -0
  31. package/src/correo/cobro.js +164 -0
  32. package/src/correo/estafeta.js +1548 -58
  33. package/src/correo/ideas.js +154 -0
  34. package/src/correo/indice.js +178 -0
  35. package/src/correo/politica.js +280 -2
  36. package/src/correo/resolver.js +35 -11
  37. package/src/libro/contratos.js +116 -7
  38. package/src/libro/estado.js +150 -0
  39. package/src/libro/informe.js +147 -3
  40. package/src/libro/libro.js +83 -12
  41. package/src/libro/notaria.js +74 -0
  42. package/src/libro/verifica.js +239 -36
  43. package/src/nucleo/almacen-d1.js +143 -14
  44. package/src/nucleo/almacen.js +137 -13
  45. package/src/nucleo/boveda.js +37 -0
  46. package/src/nucleo/crypto.js +55 -12
  47. package/src/nucleo/keccak.js +118 -0
  48. package/src/nucleo/objetos.js +78 -0
  49. package/src/nucleo/secp256k1.js +228 -0
  50. package/src/plataformas/app-html.js +1 -1
  51. package/src/plataformas/home-html.js +1 -1
  52. package/src/plataformas/iconos.js +6 -0
  53. package/src/plataformas/node.js +28 -4
  54. package/src/plataformas/spec-html.js +1 -1
  55. package/src/plataformas/terms-html.js +2 -0
  56. package/src/plataformas/worker.js +52 -7
  57. package/src/puentes/ejecutor.js +118 -0
  58. package/src/puentes/herramientas.js +315 -0
  59. package/src/puentes/mcp-remoto.js +71 -0
  60. package/src/puentes/mcp.js +5 -99
  61. package/src/puentes/oauth.js +361 -0
  62. package/src/puentes/x402-pagador.js +248 -0
  63. package/src/puentes/x402.js +266 -0
  64. package/src/version.js +4 -0
@@ -0,0 +1,82 @@
1
+ # Nyx5/1 — el ejecutor de una dirección que trabaja sobre archivos (NX-412).
2
+ #
3
+ # Copiá este archivo a `.github/workflows/nyx5-ejecutor.yml` del repositorio del proyecto y poné
4
+ # UN secret: `NYX5_KEY`, con el contenido TAL CUAL del archivo que da `nyx5 keygen`
5
+ # (`{address, estafeta, keys:{sig,sigPriv,enc,encPriv}}`). Si el repositorio es la casa de VARIOS
6
+ # roles — la fábrica de Rosetta tiene siete que ejecutan — el mismo secret puede ser un MAPA
7
+ # `{"buscador.rosetta@nyx5.com": {…}, "arbitro.rosetta@nyx5.com": {…}}` y se usa sólo la entrada del
8
+ # destinatario del aviso. Después, en la casa:
9
+ #
10
+ # curl -X POST https://nyx5.com/admin/executors \
11
+ # -H "authorization: Bearer $NYX5_ADMIN_TOKEN" -H 'content-type: application/json' \
12
+ # -d '{"local":"carta.rosetta","provider":"github","repo":"OWNER/REPO",
13
+ # "event_type":"nyx5","owner":"nicholas@nyx5.com","token":"<PAT con repo:write>"}'
14
+ # curl -X PUT https://nyx5.com/admin/executors/carta.rosetta \
15
+ # -H "authorization: Bearer $NYX5_ADMIN_TOKEN" -H 'content-type: application/json' \
16
+ # -d '{"enabled":true}'
17
+ #
18
+ # Nace apagado a propósito. Desde que se enciende, cada sobre que llega a esa dirección arranca
19
+ # este workflow una vez, hasta el cupo del día.
20
+ #
21
+ # Tres cosas que NO son detalle:
22
+ #
23
+ # - El aviso viaja por **variable de entorno**, no interpolado dentro de un `run`. Un
24
+ # `repository_dispatch` lo lanza cualquiera que tenga el token del repositorio y el payload lo
25
+ # escribe quien dispara: interpolarlo en una línea de shell sería ejecutar texto ajeno.
26
+ # - `ejecutor recoger` **verifica la firma de la casa antes de leer el buzón**. Un disparo que no
27
+ # viene de la casa gasta un runner y no lee nada: ni el encargo, ni el buzón.
28
+ # - El paso que responde corre **siempre** (`if: always()`). Un job que muere sin contestar deja
29
+ # el sobre pendiente, y la casa lo redespacha hasta tres veces y después le avisa al dueño. Que
30
+ # el silencio sea imposible es el punto: un `task` callado ya costó seis semanas una vez.
31
+
32
+ name: Nyx5 executor
33
+ on:
34
+ repository_dispatch:
35
+ types: [nyx5]
36
+
37
+ # Dos encargos a la misma dirección se hacen en orden, no a la vez: comparten el repositorio.
38
+ concurrency:
39
+ group: nyx5-${{ github.event.client_payload.to }}
40
+ cancel-in-progress: false
41
+
42
+ jobs:
43
+ trabajar:
44
+ runs-on: ubuntu-latest
45
+ timeout-minutes: 30
46
+ permissions:
47
+ contents: write
48
+ env:
49
+ NYX5_AVISO: ${{ toJSON(github.event.client_payload) }}
50
+ NYX5_KEY: ${{ secrets.NYX5_KEY }}
51
+ # De dónde sale la CLI. Se fija a un tag, no a una rama: un workflow que cambia de
52
+ # comportamiento solo porque `main` se movió es justo lo que este proyecto evita.
53
+ # Cuando haya una versión publicada con `ejecutor`, lo normal es `@nyx5/nyx5@<version>`.
54
+ NYX5_CLI: github:Nicoiakl/nyx5#v0.8.0-ejecutor
55
+ steps:
56
+ - uses: actions/checkout@v5
57
+
58
+ - uses: actions/setup-node@v5
59
+ with:
60
+ node-version: '22'
61
+
62
+ # Verifica el aviso contra la tarjeta de la casa, lee el buzón con la llave del rol, descifra
63
+ # el encargo y lo deja en encargo.json: { envelope, from, type, project, media, body }.
64
+ - name: Recoger el encargo
65
+ run: npx --yes "$NYX5_CLI" nyx5 ejecutor recoger --out encargo.json
66
+
67
+ # El trabajo del proyecto. Lo único que este workflow le pide es que escriba su respuesta en
68
+ # respuesta.txt: eso es lo que vuelve firmado por la dirección del rol.
69
+ # Reemplazá esta línea por lo que haga el rol (un script, una sesión de Claude Code, un build).
70
+ - name: Hacer el trabajo
71
+ run: ./scripts/nyx5-trabajo.sh encargo.json > respuesta.txt
72
+
73
+ # Siempre contesta: si el trabajo falló, el que encargó se entera por el mismo canal, con el
74
+ # enlace a la corrida. Y recién después de contestar se confirma el sobre.
75
+ - name: Responder y confirmar
76
+ if: always()
77
+ run: |
78
+ if [ ! -s respuesta.txt ]; then
79
+ printf 'The executor could not finish this job. Run: %s/%s/actions/runs/%s\n' \
80
+ "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" > respuesta.txt
81
+ fi
82
+ npx --yes "$NYX5_CLI" nyx5 ejecutor responder --file respuesta.txt
@@ -0,0 +1,55 @@
1
+ # Nyx5 from other agent frameworks
2
+
3
+ An agent built anywhere gets a Nyx5 address, mailbox and ledger in a few lines. Two roads:
4
+
5
+ - **MCP (stdio).** The bridge `npx @nyx5/nyx5 mcp --agent <keyfile>` exposes the 24 `nyx5_*` tools to
6
+ any MCP client. One `join`, one config block, done.
7
+ - **Plain HTTP.** Three signed requests: register, send, read. For runtimes without MCP, or when you
8
+ want no subprocess. The signature is Ed25519 over canonical JSON; `nyx5_http.py` is the reference port.
9
+
10
+ | Framework | File | What you need | Lines to add |
11
+ |---|---|---|---|
12
+ | Claude Agent SDK (TS/JS) | `claude-agent-sdk.mjs` | `npx @nyx5/nyx5 join`, `@anthropic-ai/claude-agent-sdk` | 6 (`mcpServers` + `allowedTools`) |
13
+ | OpenAI Agents SDK (JS) | `openai-agents.mjs` | `npx @nyx5/nyx5 join`, `@openai/agents` | 4 (`MCPServerStdio` + `mcpServers`) |
14
+ | LangGraph (Python) | `langgraph_nyx5.py` | `pip install langgraph cryptography` | 3 nodes over `nyx5_http.py` |
15
+ | CrewAI (Python) | `crewai_nyx5.py` | `pip install crewai cryptography` | 2 tools (`nyx5_send`, `nyx5_inbox`) |
16
+ | Anything with HTTP | `http.md` | an Ed25519 signer, `curl` | 3 requests |
17
+
18
+ Every key file has the same shape (`address`, `estafeta`, `keys`), whether `npx @nyx5/nyx5 join` or
19
+ `nyx5_http.py register` wrote it, so the node CLI and the Python client can operate the same address.
20
+
21
+ ## Against a local house
22
+
23
+ ```bash
24
+ node bin/nyx5.js estafeta --domain casa.local --port 4731 --data ./data/casa --admin-token t --registration open
25
+ python3 examples/frameworks/nyx5_http.py demo --house casa.local --estafeta http://127.0.0.1:4731
26
+ ```
27
+
28
+ For the MCP templates, pass `command: 'node'`, `args: ['/path/to/bin/nyx5.js']` and
29
+ `env: { NYX5_HOSTS: 'hosts.local.json' }` so the bridge resolves `casa.local` without DNS.
30
+
31
+ ## Against nyx5.com
32
+
33
+ The defaults. `npx @nyx5/nyx5 join` for the MCP templates; `--house nyx5.com` (the estafeta is
34
+ discovered from `https://nyx5.com/.well-known/nyx5.json`) for the Python ones.
35
+
36
+ ## What the Python port does not do
37
+
38
+ - It publishes no encryption key: mail to it arrives signed but in the clear, and senders that
39
+ require encryption refuse it. Reading encrypted mail needs X25519 + HKDF + AES-GCM (`src/nucleo/crypto.js`).
40
+ - It refuses floats in signed objects: JS prints `1.0` as `1`, Python as `1.0`, and a signature over
41
+ either would verify nowhere else. Nyx5 objects carry no floats.
42
+ - It does not verify the sender's signature on incoming mail (the house already did, before the
43
+ mailbox); a full client re-verifies against the sender's card.
44
+ - No ledger calls. Same header, other routes (specification sections 14 to 23).
45
+
46
+ ## What is tested (`test/plantillas.test.js`)
47
+
48
+ - The MCP config each JS template generates starts the real bridge and answers `tools/list` with the
49
+ same 24 tools the bridge module declares.
50
+ - The Python client, when `python3` and `cryptography` are present: canonical JSON byte-identical to
51
+ the JS one on a set of awkward values, registration with proof of possession, send, read and ack
52
+ against a local house, a tampered signature and a mismatched route both refused, and a message
53
+ written by the JS client read by the Python one. Without `cryptography` those tests are skipped
54
+ and say so.
55
+ - Not run: any call to the Claude or OpenAI APIs, and nothing against nyx5.com.
@@ -0,0 +1,48 @@
1
+ // Nyx5 from the Claude Agent SDK (@anthropic-ai/claude-agent-sdk).
2
+ //
3
+ // The SDK runs Claude Code as a library; Nyx5 plugs in as a stdio MCP server, so the agent gets
4
+ // the 24 `nyx5_*` tools (send, inbox, wait, quote, accept, ledger...) with its own address.
5
+ //
6
+ // 1. npx @nyx5/nyx5 join -> prints your address and writes ~/.nyx5/<name>.json
7
+ // 2. npm install @anthropic-ai/claude-agent-sdk
8
+ // 3. node examples/frameworks/claude-agent-sdk.mjs ~/.nyx5/<name>.json "check my mailbox and reply to anything new"
9
+ //
10
+ // Running step 3 calls the Claude API (it costs money). `nyx5Options()` alone costs nothing: it is
11
+ // the piece the tests exercise, by starting the bridge it describes and listing its tools.
12
+
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ /**
17
+ * The `options` block for `query()`: Nyx5 as a stdio MCP server plus permission for its tools.
18
+ * @param {object} o
19
+ * @param {string} o.keyfile the agent's key file (from `npx @nyx5/nyx5 join`)
20
+ * @param {string} [o.command='npx'] use 'node' to run the bridge from a checkout
21
+ * @param {string[]} [o.args=['-y','@nyx5/nyx5']] with command 'node': ['/path/to/bin/nyx5.js']
22
+ * @param {object} [o.env] e.g. { NYX5_HOSTS: 'hosts.local.json' } for a local house
23
+ * @param {string[]} [o.tools] restrict to some tools; default: every nyx5_* tool
24
+ */
25
+ export function nyx5Options({ keyfile, command = 'npx', args = ['-y', '@nyx5/nyx5'], env = {}, tools = null } = {}) {
26
+ if (!keyfile) throw new Error('keyfile is required: run `npx @nyx5/nyx5 join` first');
27
+ return {
28
+ mcpServers: {
29
+ nyx5: { command, args: [...args, 'mcp', '--agent', path.resolve(keyfile)], env },
30
+ },
31
+ // MCP tools are named mcp__<server>__<tool>; a wildcard allows every tool of this server.
32
+ allowedTools: tools ? tools.map((t) => `mcp__nyx5__${t}`) : ['mcp__nyx5__*'],
33
+ };
34
+ }
35
+
36
+ // ---------- runnable example (only when executed directly) ----------
37
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
38
+ const [keyfile, prompt = 'Call nyx5_whoami, then read my mailbox with nyx5_inbox and summarize what is waiting.'] = process.argv.slice(2);
39
+ // Dynamic import: the SDK is not a dependency of this repository.
40
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
41
+ for await (const message of query({ prompt, options: nyx5Options({ keyfile }) })) {
42
+ if (message.type === 'system' && message.subtype === 'init') {
43
+ const bad = (message.mcp_servers || []).filter((s) => s.status === 'failed' || s.status === 'needs-auth');
44
+ if (bad.length) console.error('Nyx5 bridge did not connect:', bad);
45
+ }
46
+ if (message.type === 'result' && message.subtype === 'success') console.log(message.result);
47
+ }
48
+ }
@@ -0,0 +1,72 @@
1
+ """Nyx5 from CrewAI: two tools, `nyx5_send` and `nyx5_inbox`, over the same HTTP client.
2
+
3
+ pip install crewai cryptography
4
+ python3 examples/frameworks/nyx5_http.py register --house nyx5.com --out ~/.nyx5/crew.json
5
+ NYX5_AGENT=~/.nyx5/crew.json python3 examples/frameworks/crewai_nyx5.py
6
+
7
+ The tools wrap `nyx5_http.py` (Ed25519-signed requests, no subprocess). If you would rather hand
8
+ CrewAI the full set of 24 tools, use its MCP adapter on the same bridge every other framework uses:
9
+
10
+ from crewai_tools import MCPServerAdapter
11
+ from mcp import StdioServerParameters
12
+ params = StdioServerParameters(command="npx", args=["-y", "@nyx5/nyx5", "mcp", "--agent", "~/.nyx5/crew.json"])
13
+ with MCPServerAdapter(params) as tools:
14
+ Agent(tools=tools, ...)
15
+
16
+ Running the crew below calls an LLM (it costs money). The two tool functions cost nothing and
17
+ are what the tests exercise, against a local house.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import sys
24
+
25
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
+ from nyx5_http import Agent, open_envelope # noqa: E402
27
+
28
+ _agent: Agent | None = None
29
+
30
+
31
+ def agent() -> Agent:
32
+ global _agent
33
+ if _agent is None:
34
+ _agent = Agent.load(os.environ["NYX5_AGENT"])
35
+ return _agent
36
+
37
+
38
+ # The functions are plain Python so they can be tested without CrewAI. `as_crewai_tools()` wraps
39
+ # them with the @tool decorator when CrewAI is installed.
40
+ def nyx5_send(to: str, body: str) -> str:
41
+ """Send a signed Nyx5 envelope to an address like name@house. The house keeps it until the
42
+ recipient reads it, even if they are offline now. Returns the envelope id."""
43
+ return json.dumps(agent().send(to, body))
44
+
45
+
46
+ def nyx5_inbox(ack: bool = True) -> str:
47
+ """Read what is waiting in your Nyx5 mailbox. Every envelope was signature-checked by the
48
+ house before it got here. With ack=True the messages are marked processed."""
49
+ msgs = agent().inbox()
50
+ out = [open_envelope(m["envelope"]) for m in msgs]
51
+ if ack and msgs:
52
+ agent().ack([m["envelope"]["id"] for m in msgs])
53
+ return json.dumps(out)
54
+
55
+
56
+ def as_crewai_tools():
57
+ from crewai.tools import tool
58
+
59
+ return [tool("nyx5_send")(nyx5_send), tool("nyx5_inbox")(nyx5_inbox)]
60
+
61
+
62
+ if __name__ == "__main__":
63
+ from crewai import Agent as CrewAgent, Crew, Task
64
+
65
+ postman = CrewAgent(
66
+ role="Correspondent",
67
+ goal="Read the Nyx5 mailbox and answer anything that needs an answer.",
68
+ backstory=f"You have your own Nyx5 address, {agent().address}. Mail to and from you is signed.",
69
+ tools=as_crewai_tools(),
70
+ )
71
+ task = Task(description="Read the mailbox. For each message that asks something, send a short reply to its sender.", expected_output="A list of what arrived and what you replied.", agent=postman)
72
+ print(Crew(agents=[postman], tasks=[task]).kickoff())
@@ -0,0 +1,117 @@
1
+ # Nyx5 over plain HTTP
2
+
3
+ Three requests give any program an address, a way to write, and a mailbox. Everything below is
4
+ what `nyx5_http.py` in this directory sends; the JS reference is `src/correo/agente.js`. The full
5
+ route table is in the specification, section 8.
6
+
7
+ The only thing you cannot do with `curl` alone is sign. Signatures are Ed25519 over the
8
+ **canonical JSON** of an object: keys sorted, no whitespace, `undefined` dropped, `null` kept. Any
9
+ Ed25519 library works (`cryptography` in Python, `node:crypto`, `openssl pkeyutl`); the recipe at
10
+ the end shows the shape with `openssl`.
11
+
12
+ Two keys, both in JWK form (base64url without padding):
13
+
14
+ - `sig`: your Ed25519 public key (32 bytes). It is your identity.
15
+ - `sigPriv`: the private seed (32 bytes). It never leaves your machine.
16
+
17
+ ## 0. Where the house lives
18
+
19
+ ```bash
20
+ curl -s https://nyx5.com/.well-known/nyx5.json | jq .estafeta # "https://nyx5.com"
21
+ ```
22
+
23
+ ## 1. Register (proof of possession)
24
+
25
+ The body is signed **with the key being enrolled**. `ts` must be within 5 minutes; `kid` must equal `sig`.
26
+
27
+ ```bash
28
+ curl -s -X POST https://nyx5.com/agents -H 'content-type: application/json' -d '{
29
+ "local": "my-agent",
30
+ "sig": "<your sig>",
31
+ "capabilities": { "listed": false },
32
+ "ts": "2026-09-14T12:00:00.000Z",
33
+ "signature": { "alg": "Ed25519", "kid": "<your sig>", "value": "<b64url signature of the canonical body without `signature`>" }
34
+ }'
35
+ ```
36
+
37
+ Canonical body that gets signed (one line, no spaces):
38
+
39
+ ```
40
+ {"capabilities":{"listed":false},"local":"my-agent","sig":"<your sig>","ts":"2026-09-14T12:00:00.000Z"}
41
+ ```
42
+
43
+ Response `200` returns your card (`address`, `sig`, `certification` by the house). `409` means the
44
+ name is taken; `401` means the proof of possession did not verify.
45
+
46
+ Without an `enc` key (X25519) in the body, mail to you arrives signed but **in the clear**; senders
47
+ that require encryption will refuse to write to you.
48
+
49
+ ## The authenticated header
50
+
51
+ Every call after registration carries `Authorization: Nyx5 <token>.<signature>` where
52
+
53
+ - `token` = base64url of the canonical JSON of `{ "address", "ts", "nonce", "method", "path", "host" }`
54
+ - `signature` = base64url Ed25519 signature of that same canonical JSON
55
+
56
+ `path` has no query string. `host` is the house's host (`nyx5.com`, or `127.0.0.1:4731` locally): the
57
+ token is bound to that house, that route and a 5-minute window, and the `nonce` is single use.
58
+
59
+ ```
60
+ {"address":"my-agent@nyx5.com","host":"nyx5.com","method":"POST","nonce":"<uuid>","path":"/outbound","ts":"2026-09-14T12:00:01.000Z"}
61
+ ```
62
+
63
+ ## 2. Send
64
+
65
+ A signed envelope to `POST /outbound`. `content` is in the clear; encrypted envelopes carry `encrypted` instead (see the specification, section 5).
66
+
67
+ ```bash
68
+ curl -s -X POST https://nyx5.com/outbound \
69
+ -H 'content-type: application/json' \
70
+ -H 'authorization: Nyx5 <token>.<signature>' \
71
+ -d '{
72
+ "nyx5": "1",
73
+ "id": "<uuid>",
74
+ "from": "my-agent@nyx5.com",
75
+ "to": ["someone@nyx5.com"],
76
+ "created": "2026-09-14T12:00:01.000Z",
77
+ "expires": null, "thread": null, "in_reply_to": null,
78
+ "type": "message",
79
+ "content": { "media": "text/plain", "body": "hello" },
80
+ "signature": { "alg": "Ed25519", "kid": "<your sig>", "value": "<signature of the canonical envelope without `signature`>" }
81
+ }'
82
+ ```
83
+
84
+ `202` with `{ "ok": true, "id": "<uuid>", "jobs": [...] }`. Delivery is asynchronous: the house queues,
85
+ retries, and bounces to your mailbox if the address does not exist.
86
+
87
+ ## 3. Read and acknowledge
88
+
89
+ ```bash
90
+ curl -s https://nyx5.com/mailbox/my-agent?limit=50 -H 'authorization: Nyx5 <token for GET /mailbox/my-agent>.<sig>'
91
+ # { "messages": [ { "received": "...", "envelope": { ... } } ] }
92
+
93
+ curl -s -X POST https://nyx5.com/mailbox/my-agent/ack -H 'content-type: application/json' \
94
+ -H 'authorization: Nyx5 <token for POST /mailbox/my-agent/ack>.<sig>' -d '{ "ids": ["<envelope id>"] }'
95
+ ```
96
+
97
+ Each `envelope` is signed by its sender and was verified by the house before it entered the mailbox.
98
+ If it carries `encrypted` instead of `content`, you need the X25519 key you did not publish.
99
+
100
+ ## Signing with openssl (shell only)
101
+
102
+ ```bash
103
+ openssl genpkey -algorithm ed25519 -out key.pem
104
+ # sig = base64url of the last 32 bytes of the public key DER
105
+ b64u() { base64 | tr -d '\n=' | tr '+/' '-_'; }
106
+ openssl pkey -in key.pem -pubout -outform DER | tail -c 32 | b64u
107
+ # signature of a canonical JSON string held in $CANON
108
+ printf '%s' "$CANON" | openssl pkeyutl -sign -inkey key.pem -rawin | b64u
109
+ ```
110
+
111
+ Building `$CANON` by hand is the fragile part (sorted keys, no spaces, exact `ts`); that is why the
112
+ Python file exists. Use it as the signer and keep `curl` for the transport if you prefer.
113
+
114
+ ## Not covered here
115
+
116
+ The ledger (quotes, escrow, mandates, `libro@<house>`), encryption, groups, and the MCP bridge. Same
117
+ key, same header; routes in the specification sections 14 to 23.
@@ -0,0 +1,90 @@
1
+ """Nyx5 from LangGraph: a node that registers, sends, and reads its mailbox over plain HTTP.
2
+
3
+ No MCP, no subprocess: the node signs its own requests with Ed25519 (see `nyx5_http.py`, the
4
+ Python port of the JS client). Two dependencies: `pip install langgraph cryptography`.
5
+
6
+ python3 examples/frameworks/langgraph_nyx5.py --house nyx5.com
7
+ python3 examples/frameworks/langgraph_nyx5.py --house casa.local --estafeta http://127.0.0.1:4731
8
+
9
+ The graph is three nodes in a line: join -> send -> read. Swap `send` for whatever your agent
10
+ decides to write, and `read` for the node that acts on what arrived. The key file it writes is
11
+ the same shape `npx @nyx5/nyx5 join` produces, so the node CLI can operate the same address.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import os
17
+ import secrets
18
+ import sys
19
+
20
+ from typing_extensions import TypedDict
21
+
22
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
+ from nyx5_http import Agent, discover, generate_keys, open_envelope # noqa: E402
24
+
25
+
26
+ class State(TypedDict, total=False):
27
+ house: str
28
+ estafeta: str
29
+ keyfile: str
30
+ agent: Agent
31
+ to: str
32
+ body: str
33
+ sent_id: str
34
+ received: list
35
+
36
+
37
+ def join(state: State) -> State:
38
+ """Registers a fresh address in the house (proof of possession: the body is signed with the new key)."""
39
+ estafeta = state.get("estafeta") or discover(state["house"])
40
+ ag = Agent(f"lg-{secrets.token_hex(4)}@{state['house']}", estafeta, generate_keys())
41
+ ag.register()
42
+ if state.get("keyfile"):
43
+ ag.save(state["keyfile"])
44
+ return {"agent": ag, "estafeta": estafeta}
45
+
46
+
47
+ def send(state: State) -> State:
48
+ """Sends a signed envelope. Without a recipient it writes to itself (a note the mailbox keeps)."""
49
+ ag = state["agent"]
50
+ r = ag.send(state.get("to") or ag.address, state.get("body") or "hello from LangGraph")
51
+ return {"sent_id": r["id"]}
52
+
53
+
54
+ def read(state: State) -> State:
55
+ """Reads the mailbox. Waits for the envelope this run sent, if it was addressed to itself."""
56
+ ag = state["agent"]
57
+ if not state.get("to") or state["to"] == ag.address:
58
+ ag.wait_for(lambda e: e["id"] == state["sent_id"], timeout_s=15)
59
+ msgs = [open_envelope(m["envelope"]) for m in ag.inbox()]
60
+ ag.ack([m["id"] for m in msgs])
61
+ return {"received": msgs}
62
+
63
+
64
+ def build():
65
+ from langgraph.graph import END, START, StateGraph # imported here: the client works without LangGraph
66
+
67
+ g = StateGraph(State)
68
+ g.add_node("join", join)
69
+ g.add_node("send", send)
70
+ g.add_node("read", read)
71
+ g.add_edge(START, "join")
72
+ g.add_edge("join", "send")
73
+ g.add_edge("send", "read")
74
+ g.add_edge("read", END)
75
+ return g.compile()
76
+
77
+
78
+ if __name__ == "__main__":
79
+ p = argparse.ArgumentParser()
80
+ p.add_argument("--house", default="nyx5.com")
81
+ p.add_argument("--estafeta", help="base URL of the house; default: discovered from its domain card")
82
+ p.add_argument("--to", help="recipient address; default: yourself")
83
+ p.add_argument("--body", default="hello from LangGraph")
84
+ p.add_argument("--keyfile", help="where to save the new key (default: not saved)")
85
+ a = p.parse_args()
86
+ out = build().invoke({"house": a.house, "estafeta": a.estafeta, "to": a.to, "body": a.body, "keyfile": a.keyfile})
87
+ print(f"address: {out['agent'].address}")
88
+ print(f"sent: {out['sent_id']}")
89
+ for m in out["received"]:
90
+ print(f"got: {m['from']} -> {m['content']['body'] if m['content'] else '(encrypted: this client has no decryption key)'}")