@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.
- package/README.md +50 -7
- package/bin/nyx5.js +81 -2
- package/demo/edge-local.mjs +41 -0
- package/demo/x402-pagar.mjs +64 -0
- package/demo/x402-testnet.mjs +80 -0
- package/docs/ARQUITECTURA.md +354 -0
- package/docs/SPEC.md +414 -15
- package/examples/ci/nyx5-ejecutor.yml +82 -0
- package/examples/frameworks/README.md +55 -0
- package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
- package/examples/frameworks/claude-agent-sdk.mjs +48 -0
- package/examples/frameworks/crewai_nyx5.py +72 -0
- package/examples/frameworks/http.md +117 -0
- package/examples/frameworks/langgraph_nyx5.py +90 -0
- package/examples/frameworks/nyx5_http.py +255 -0
- package/examples/frameworks/openai-agents.mjs +41 -0
- package/examples/hola-mundo.mjs +4 -4
- package/migrations/0006_puente_remoto.sql +19 -0
- package/migrations/0007_indice_reputacion.sql +31 -0
- package/migrations/0008_notaria.sql +18 -0
- package/migrations/0009_contratos_estado.sql +4 -0
- package/package.json +4 -2
- package/server.json +2 -2
- package/src/correo/acciones.js +216 -0
- package/src/correo/adjuntos.js +185 -0
- package/src/correo/agente.js +370 -22
- package/src/correo/asistente.js +599 -0
- package/src/correo/ci.js +262 -0
- package/src/correo/cobro.js +164 -0
- package/src/correo/estafeta.js +1548 -58
- package/src/correo/ideas.js +154 -0
- package/src/correo/indice.js +178 -0
- package/src/correo/politica.js +280 -2
- package/src/correo/resolver.js +35 -11
- package/src/libro/contratos.js +116 -7
- package/src/libro/estado.js +150 -0
- package/src/libro/informe.js +147 -3
- package/src/libro/libro.js +83 -12
- package/src/libro/notaria.js +74 -0
- package/src/libro/verifica.js +239 -36
- package/src/nucleo/almacen-d1.js +143 -14
- package/src/nucleo/almacen.js +137 -13
- package/src/nucleo/boveda.js +37 -0
- package/src/nucleo/crypto.js +55 -12
- package/src/nucleo/keccak.js +118 -0
- package/src/nucleo/objetos.js +78 -0
- package/src/nucleo/secp256k1.js +228 -0
- package/src/plataformas/app-html.js +1 -1
- package/src/plataformas/home-html.js +1 -1
- package/src/plataformas/iconos.js +6 -0
- package/src/plataformas/node.js +28 -4
- package/src/plataformas/spec-html.js +1 -1
- package/src/plataformas/terms-html.js +2 -0
- package/src/plataformas/worker.js +52 -7
- package/src/puentes/ejecutor.js +118 -0
- package/src/puentes/herramientas.js +315 -0
- package/src/puentes/mcp-remoto.js +71 -0
- package/src/puentes/mcp.js +5 -99
- package/src/puentes/oauth.js +361 -0
- package/src/puentes/x402-pagador.js +248 -0
- package/src/puentes/x402.js +266 -0
- 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.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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)'}")
|