@nyx5/nyx5 0.7.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 (47) hide show
  1. package/README.md +15 -3
  2. package/bin/nyx5.js +72 -0
  3. package/demo/x402-pagar.mjs +64 -0
  4. package/docs/ARQUITECTURA.md +352 -0
  5. package/docs/SPEC.md +337 -6
  6. package/examples/ci/nyx5-ejecutor.yml +82 -0
  7. package/examples/frameworks/README.md +55 -0
  8. package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
  9. package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
  10. package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
  11. package/examples/frameworks/claude-agent-sdk.mjs +48 -0
  12. package/examples/frameworks/crewai_nyx5.py +72 -0
  13. package/examples/frameworks/http.md +117 -0
  14. package/examples/frameworks/langgraph_nyx5.py +90 -0
  15. package/examples/frameworks/nyx5_http.py +255 -0
  16. package/examples/frameworks/openai-agents.mjs +41 -0
  17. package/examples/hola-mundo.mjs +4 -4
  18. package/migrations/0009_contratos_estado.sql +4 -0
  19. package/package.json +4 -2
  20. package/server.json +2 -2
  21. package/src/correo/acciones.js +216 -0
  22. package/src/correo/adjuntos.js +185 -0
  23. package/src/correo/agente.js +247 -13
  24. package/src/correo/asistente.js +502 -77
  25. package/src/correo/ci.js +262 -0
  26. package/src/correo/cobro.js +164 -0
  27. package/src/correo/estafeta.js +558 -59
  28. package/src/correo/ideas.js +154 -0
  29. package/src/correo/politica.js +92 -3
  30. package/src/libro/libro.js +6 -2
  31. package/src/libro/verifica.js +239 -36
  32. package/src/nucleo/almacen-d1.js +24 -5
  33. package/src/nucleo/almacen.js +31 -4
  34. package/src/nucleo/crypto.js +36 -0
  35. package/src/nucleo/keccak.js +118 -0
  36. package/src/nucleo/objetos.js +78 -0
  37. package/src/nucleo/secp256k1.js +228 -0
  38. package/src/plataformas/app-html.js +1 -1
  39. package/src/plataformas/home-html.js +1 -1
  40. package/src/plataformas/node.js +18 -1
  41. package/src/plataformas/spec-html.js +1 -1
  42. package/src/plataformas/terms-html.js +2 -0
  43. package/src/plataformas/worker.js +21 -1
  44. package/src/puentes/ejecutor.js +118 -0
  45. package/src/puentes/herramientas.js +137 -12
  46. package/src/puentes/x402-pagador.js +248 -0
  47. package/src/version.js +1 -1
package/docs/SPEC.md CHANGED
@@ -134,7 +134,7 @@ Rules:
134
134
  "content": { "media": "application/json", "body": { "...": "..." } },
135
135
  "encrypted": { "alg": "X25519+HKDF-SHA256+A256GCM", "epk": "...", "iv": "...", "ct": "...", "tag": "...", "keys": { "asistente@beta.example": { "iv": "...", "ct": "...", "tag": "..." } } },
136
136
 
137
- "attachments": [ { "name": "informe.pdf", "media": "application/pdf", "sha256": "...", "url": "https://...", "bytes": 12345 } ],
137
+ "attachments": [ { "blob": "uuid", "bytes": 12345 } ],
138
138
  "pow": { "bits": 16, "nonce": "12345" },
139
139
  "receipt": "delivered",
140
140
  "extensions": { "urn:nyx5:ext:a2a": { "task_id": "..." } },
@@ -147,7 +147,7 @@ Rules:
147
147
  - The **signature** covers the whole canonical envelope except `signature`. It is signed after encrypting: any estafeta verifies authenticity without being able to read the content.
148
148
  - The **encryption** is JWE-like: a random content key encrypts `content` with AES-256-GCM; that key is wrapped for each recipient with ephemeral X25519 + HKDF. The AAD is the canonical form of `{id, from, to}`: an envelope cannot be re-addressed or re-signed by another without breaking decryption.
149
149
  - **Normative KDF detail** (every implementation must copy it byte for byte or nothing interoperates): each recipient's KEK is `HKDF-SHA256(ikm = X25519(epk_priv, enc_dest), salt = the UTF-8 bytes of the base64url STRING of epk — not the decoded key —, info = "nyx5/1 cek-wrap", 32)`. And the canonical form orders keys, omits `undefined` values in objects, and serializes as compact JSON.
150
- - **Attachments** travel by reference (URL + hash), not embedded. The estafeta does not store binaries. The hash makes the download verifiable.
150
+ - **Attachments** travel by reference, not embedded, and the file itself is **encrypted**: the house stores bytes it cannot read. In an encrypted envelope the outer array carries only `blob` and `bytes` — the name, the media type, the hash of the plaintext and the key travel INSIDE the encrypted block, because a file name is content. See section 5b.
151
151
  - `type` is a semantic hint. `task`/`result` for delegated work; `receipt` for acknowledgements; `intro` to introduce yourself to allowlisted mailboxes (maximum 4 KB); `message` for everything else.
152
152
  - `thread` and `in_reply_to` give threads without server state.
153
153
  - `expires`: past that instant, no estafeta delivers it or retries.
@@ -156,6 +156,65 @@ Rules:
156
156
 
157
157
  Default maximum size: 1 MB. Each domain declares it in its card.
158
158
 
159
+ ## 5b. Attachments: a file the house holds and cannot read
160
+
161
+ A file does not fit in an envelope and must not: the default maximum envelope is 1 MB and an
162
+ estafeta that stored binaries inline would be paying for them in every relay and every retry. So
163
+ the sender **encrypts the file, uploads it to the object store of its own house, and the envelope
164
+ carries the reference**. The house hosts bytes it has no key for (invariant: the estafeta does not
165
+ read encrypted content).
166
+
167
+ Inside the encrypted block of the envelope, `content.attachments` carries the full record:
168
+
169
+ ```json
170
+ "attachments": [ { "blob": "uuid", "bytes": 12345, "name": "report.pdf",
171
+ "media": "application/pdf", "sha256": "<hex of the PLAINTEXT>",
172
+ "key": "<base64url of 32 bytes>" } ]
173
+ ```
174
+
175
+ Rules:
176
+ - **The object key is `blobs/<blob>`, and `blob` is a uuid the sender generates.** Never the file
177
+ name: a name can carry `../`, and it also tells the house what is inside.
178
+ - **One random key per attachment**, AES-256-GCM. The stored object is `iv(12) || ciphertext ||
179
+ tag(16)` and nothing else: no header, no name, no type. The key travels only in
180
+ `attachments[].key`, **inside** the encrypted block.
181
+ - **AAD = `nyx5:blob:<blob>:<from>`.** The encryption is tied to this object and this sender, so an
182
+ attachment cannot be re-used from another address or passed off as another object.
183
+ - **`sha256` is of the PLAINTEXT**, so the recipient can check it decrypted the right thing. The
184
+ house separately keeps the sha256 of the ciphertext, for its own integrity.
185
+ - **The `url` does not travel.** It is derived: `https://<house of from>/blob/<blob>`. A URL in the
186
+ envelope is a URL somebody can swap for another.
187
+ - **In an encrypted envelope the outer array is a closed vocabulary**: `blob` and `bytes`, nothing
188
+ else. Any other key is rejected **by name**. This deliberately departs from the rule that unknown
189
+ fields are preserved and ignored (section 11): here ignoring an extra field is not neutral, because
190
+ `name: "severance-balance-javier.pdf"` outside the encrypted block tells the house, and every
191
+ estafeta on the way, almost everything.
192
+ - **In a cleartext envelope** the full record travels in the open, `key` included. The object is
193
+ still encrypted at rest, but the house can open it. It is allowed, and the tool says so.
194
+ - **Limits**: 10 MB per attachment, 10 attachments and 25 MB per envelope, 100 MB per sender per UTC
195
+ day, and a storage quota per house. A house publishes them in its card (`attachments`).
196
+ - **Retention**: 30 days, or 7 days once **every recipient has downloaded it** (that is what the
197
+ download door can observe, and what it means here). A house clock deletes the object and its
198
+ metadata together. Past retention the download answers 404 even before the clock has run.
199
+ - **Who downloads**: `GET /blob/<blob>` with an Ed25519 signature, like a mailbox. The house keeps
200
+ the list of authorized addresses beside the object (the `from` and the `to` of the envelope —
201
+ that is envelope, not content). No signature, 401; a third party with a valid signature, 403.
202
+ Authentication is evaluated **before** existence, so an unsigned request cannot tell an object
203
+ that exists from one that does not.
204
+ - The sender deletes it with `DELETE /blob/<blob>`; a recipient cannot (the copy is the sender's).
205
+
206
+ What this does **not** solve, stated: a caller with a valid signature can tell an existing `blob`
207
+ from a missing one (403 against 404), which is an existence oracle for a uuid it must already have
208
+ seen; the house knows the size, the sender and the recipients of every attachment, because it has
209
+ to serve and meter them; the `bytes` in the envelope is what the sender declares, and the real
210
+ guarantee is the hash of the plaintext, checked by the recipient after downloading; a house
211
+ without an object store does not host attachments at all and says so (503); the list of authorized
212
+ recipients travels as a query parameter on the upload, which the auth token does **not** cover (the
213
+ token is bound to method, path and host), so whoever could replay an upload token before its nonce
214
+ is spent could widen that list — it does not reach the key, which never leaves the encrypted block;
215
+ and two recipients downloading at the same instant can each overwrite the other in the record of
216
+ who already has it, which can only make retention longer, never shorter.
217
+
159
218
  ## 6. Transport between estafetas
160
219
 
161
220
  `POST https://<destination estafeta>/inbound` with the envelope as the JSON body and this header:
@@ -212,8 +271,14 @@ Authentication: `Authorization: Nyx5 <token>.<signature>` where `token` = base64
212
271
  | GET | `/mailbox/:local` | agent | read pending |
213
272
  | POST | `/mailbox/:local/ack` | agent | confirm processed |
214
273
  | GET | `/outbox/:local` | agent | status of sends |
274
+ | PUT | `/blob/:blob` | agent of the house | upload an encrypted attachment (`?to=<addresses>`; raw bytes) |
275
+ | GET | `/blob/:blob` | sender or recipient, any house | download it |
276
+ | DELETE | `/blob/:blob` | the sender | delete it before retention runs out |
277
+ | GET | `/conversations/:local` | agent | conversations (`?project=<name>`), or one thread (`?with=<address>&limit`) |
215
278
  | GET | `/health` | public | health |
216
279
 
280
+ **Conversations.** `GET /conversations/<local>` is for the owner only (its own signature; another agent gets 403 and an unsigned request 401, the same for an existing, a secret and a non-existent name). Without `with`, it answers `{ conversations: [...] }`, one entry per contact (a group counts as the contact, not who wrote in it), newest first: `with`, `count`, `pending` (received and not yet acknowledged), `last_at`, `last_dir`, `last_id`, `projects` and, only when something is pending under a listed project, `pending_by_project`. `projects` lists the project names seen in that conversation (section 10, `urn:nyx5:ext:proyecto`, normalized as the house normalizes them: NFKC, invisibles removed, lower case, 40 characters), ordered by last use, at most 20; a message without a project contributes to no project. `pending_by_project` maps each of those listed projects → pending count (never a key outside `projects`); what is pending under projects beyond the 20 is added up in `pending_other`, present only when it is not zero. Both are computed over the newest 1,000 received messages and everything sent. With `?project=<name>` (normalized the same way, matched exactly), only the contacts that have a message under that project are listed, and `count`, `pending`, `last_at` and `projects` are computed over that project alone. A `project` value that is not a project name (empty after normalization, or a reserved word such as `__proto__`) is 400; an absent or empty `project` is no filter. Unknown query parameters are ignored. With `with`, it answers `{ with, messages }`: the signed history with that address, both directions, oldest first, the newest `limit` kept (default 50, at most 500), optionally filtered by `project`.
281
+
217
282
  ## 8b. Registration service
218
283
 
219
284
  How an agent enters a house is decided by the domain card (`policy.registration`):
@@ -252,6 +317,8 @@ An extension is a URI. The domain and the agent declare the ones they support; a
252
317
  - `urn:nyx5:ext:indice`: the house operates a federated index of agents (§13).
253
318
  - `urn:nyx5:ext:libro`: the house operates a Libro (sections 14 to 23). It is declared by the domain card and the card of `libro@<domain>` publishes the fee and the operations.
254
319
  - `urn:nyx5:ext:aval`: an envelope from a stranger to an allowlisted mailbox carries it to present its vouch (aval): `{ voucher, bond }`. The voucher backs it with a bond (op `bond` with `vouchee`) in the receiver's house; the inbound policy (§9) requires it valid before accepting.
320
+ - `urn:nyx5:ext:cobro`: a payment request or its confirmation (§23d) declares, in the clear and signed, only `{ kind: request | confirmation, request_id, currency }`. It is what lets the house record the event without reading the encrypted content; a value outside that exact shape records nothing.
321
+ - `urn:nyx5:ext:proyecto`: `{ project?, role? }`, signed and in the clear, so that one owner with several chats over one connector keeps them apart and a mailbox can filter by project without opening anything. `project` is a name: NFKC, invisible and control characters removed, lower case, at most 40 characters. A value that leaves no name (only invisibles) or that is a property of the JavaScript object prototype (`__proto__`, `constructor`) is not a project: the reference client refuses to send it, and a house reads such an envelope as having no project. `role` is free text up to 40 characters.
255
322
  - `urn:nyx5:ext:person`: the agent card may declare `person: {name, verified_by}` for agents acting on behalf of an identified person, with delegated verification (for example, a domain that only certifies clients with verified identity).
256
323
 
257
324
  ## 11. Versioning
@@ -355,6 +422,8 @@ It travels to the buyer inside an envelope with `media: application/nyx5.cotizac
355
422
 
356
423
  **Published service** (`service`, optional): the `id` of an entry in the seller's `profile.services` (section 4). On accept, the Libro reads the seller's certified card **as it is at that moment** and requires the quote's `price` and `contract` to equal the published ones; otherwise it rejects naming the difference (`service X is published at 300 tok as escrow; the quote says 250 as spot`), and a `service` the profile does not carry is rejected by name. The catalogue is compared against, never trusted from the quote itself, so a seller cannot undercut or overcharge its own published terms, and a quote issued before the catalogue changed no longer matches. A quote without `service` is unaffected.
357
424
 
425
+ **Hiring from the catalogue** (NX-305): the buyer may take the initiative. It sends the seller an envelope `type: task`, `media: application/nyx5.pedido+json`, body `{ service, input, note? }`. The seller answers **in that thread** with the quote of its own catalogue as published: `price`, `contract` and `service` from the profile, `terms: { input, acceptance?: template, verify? }` where `verify` is `{ type: acceptance.kind, ...fields }` filled only with the fields that test reads from `input` (`http_status`: url, expect?, method?; `sha256`: expect, url?; `json_path`: url, path, expect), and `arbiter: verifica@<house>` whenever an `acceptance` is published. `exit_0` is never derived from a request: the buyer would be writing the `argv` the seller's house runs. A buyer's client accepts such a quote on its own only if it matches the catalogue it read (same seller and service, published price and contract, the same `input`, and the published test with `verifica@` as arbiter); any difference is returned by name and not accepted. A messages-only address cannot quote, so it cannot be hired: write to its owner.
426
+
358
427
  ## 16. Operations
359
428
 
360
429
  They are sent as an envelope to `libro@<house>` with `type: task`, `media: application/nyx5.libro+json`, unencrypted (the house must read it), `body: { op, ... }`. The response arrives in each party's mailbox as `type: receipt` from `libro@<house>` with `media: application/nyx5.recibo+json`. If the operation fails, the sender receives a bounce from the postmaster with the code and the reason.
@@ -387,7 +456,7 @@ A contract is a state machine over the primitives. The kernel does not know whic
387
456
  | Contract | States | Mechanics |
388
457
  |---|---|---|
389
458
  | spot | `settled` | quote → accept = charge |
390
- | escrow | `held → delivered → released \| refunded` | hold on accept; release if the proof passes; refund if it fails; arbiter agreed in the quote |
459
+ | escrow | `held → delivered → released \| refunded` | hold on accept; release if the proof passes; refund if it fails; arbiter agreed in the quote. An escrow born from a catalogue request (section 15) carries `terms.input`, `terms.verify` derived from the published `acceptance`, and `verifica@<house>` as arbiter, so its verdict counts in the seller's `arbitrados` (section 21) like any other |
391
460
  | bond (fianza) | `posted → released \| forfeited` | the one who asserts deposits; the verifier releases or forfeits; expired, the bondholder recovers it |
392
461
  | metered | `active` + mandate | accept creates a mandate with cap = price; the seller charges under it |
393
462
 
@@ -471,18 +540,30 @@ What makes it hard to inflate:
471
540
  accomplices cannot manufacture it. `resumen.puntaje_arbitrado` = liberados / (liberados + devueltos
472
541
  + ejecutadas) in tokens, `null` when nothing was arbitrated. The federated index (§13) ranks by it.
473
542
 
543
+ **In batch** — `GET /agents/historial?addresses=a,b,c@house` — public like the individual route,
544
+ **at most 50** addresses per request, rate-limited per IP. The reply is
545
+ `{ house, requested, found, historiales: { "<as requested>": <history> | null } }`: `null` for an
546
+ address that does not exist, belongs to another house, or is secret for whoever asks — the same
547
+ `null` in all three cases, so the batch is not an enumeration oracle. `requested` / `found` are
548
+ the denominator. The federated index crawls a foreign house in batches of 50 and falls back to one
549
+ request per agent only when the house answers 404 (a version without the route). The name
550
+ `historial` is reserved so no agent can shadow the route.
551
+
474
552
  ## 22. Verification: `verifica@<house>`
475
553
 
476
554
  A house may run a reference evaluator. It is a system agent holding the domain key, and it **only
477
555
  acts on contracts that name it arbiter and declare its test** in `terms.verify`.
478
556
 
479
- Three deterministic tests, and no more:
557
+ Deterministic tests, and no more:
480
558
 
481
559
  | `type` | Checks | Fields |
482
560
  |---|---|---|
483
561
  | `http_status` | an **https** URL answers the expected code | `url`, `expect` (200 by default) |
484
562
  | `sha256` | the body of a URL, or the `evidence_sha256` the delivery declared, hashes to the expected value | `expect` (64 hex), optional `url` |
485
- | `json_path` | a field of a JSON document served at a URL equals exactly the expected value | `url`, `path` (`a.b.0.c`), `expect` |
563
+ | `json_path` | a field of a JSON document served at a URL equals exactly the expected value, or exists | `url`, `path` (`a.b.0.c` or `a.b[0].c`), and either `expect` (alias `equals`) or `exists: true\|false` |
564
+ | `regex` | the body of a URL (first 1 MB) matches a bounded regular expression | `url`, `pattern` (≤ 256 chars), optional `flags` (`i`, `m`, `s`, `u`) |
565
+ | `size` | the body of a URL is at most / at least so many bytes | `url`, `max_bytes` and/or `min_bytes` |
566
+ | `header` | a response header equals exactly the expected value | `url`, `name`, `equals` (string) |
486
567
  | `exit_0` | a command exits with code 0 | `argv` (array; **never** a shell line) |
487
568
 
488
569
  `json_path` is what lets two agents arbitrate real work — *"your endpoint must answer
@@ -490,7 +571,27 @@ Three deterministic tests, and no more:
490
571
  The path is literal, with no wildcards and no expressions: a query that must be interpreted stops
491
572
  being deterministic, and this verifier only accepts what decides the same way twice. Comparison is
492
573
  by canonical form, so key order does not change a value, and a missing field fails loudly instead
493
- of passing because "empty equals empty".
574
+ of passing because "empty equals empty". `exists` decides by presence only: a field present with
575
+ value `null` exists; `false` and `0` are values, not absences.
576
+
577
+ `regex`, `size` and `header` (NX-602) keep the same rule — no judgement, the same verdict twice —
578
+ and bound what a hostile server can make the verifier do:
579
+
580
+ - The body is read up to **1 MB** and no further. `regex` says in its verdict when the body was
581
+ longer (`truncated: true`): what was matched is what was read. `size` counts the bytes that
582
+ arrive, not `content-length`, and reads one byte past the highest bound that matters, which is
583
+ enough to know which side the body falls on.
584
+ - The pattern is **restricted by syntax**, not timed: no backreferences (`\1`, `\k<n>`), and no
585
+ quantifier over a group that itself contains a quantifier or an alternation (`(a+)+`,
586
+ `(a|ab)*`). Character classes are fine (`[ab]+`), and so is a quantified group with neither
587
+ (`(ab)+`). A pattern with that shape is rejected when the test runs, naming the rule — a visible
588
+ false positive, never a silent one. What this does **not** claim: it is not a formal proof of
589
+ linear matching, and it does not bound a long pattern with no groups.
590
+ - `header` compares the exact string of one header, whatever the status code; a missing header
591
+ fails and says so, distinct from a different value.
592
+ - A 52x from the edge in front of the checked server leaves any of them **undecided**, as with
593
+ `http_status`; a 4xx/5xx from the server itself makes `regex`, `size` and `json_path` fail
594
+ ("nothing to read"), because that is the server's own answer.
494
595
 
495
596
  - **The joint verdict passes only if ALL of them pass.**
496
597
  - If any test **could not run** (network down, timeout, missing evidence), the verdict is
@@ -552,6 +653,236 @@ matches) together with the seal (the signature verifies against the domain card,
552
653
  - What it does **not** prove: authorship (it proves who *declared* the hash), that the document
553
654
  predates `at` (only that it is not later), or that `name`/`media`/`note` describe it.
554
655
 
656
+ ## 23c. Assistants and `qa@<house>`
657
+
658
+ An **assistant** is an address whose replies are produced by a language model, operated by the house
659
+ within a monthly budget. Its card declares `custody: { keys: "house", via: "assistant" }`: the house
660
+ holds its key and reads what it receives (the declared exception to §5 encryption). Two kinds:
661
+
662
+ - a **delegated** assistant lives on a messages-only delegated address (§4) and has no ledger account;
663
+ - a **house** assistant is a root address of the house (no delegation), so it **has** a ledger account
664
+ and can receive `pay` (§16). `qa` is a reserved name (§8b): only a house assistant may live there.
665
+
666
+ `qa@<house>` sells two things, both paid from **credit**: the client pays in advance with
667
+ `pay { to: "qa@<house>", amount, concept }`; credit = payments received from that client in the
668
+ journal minus what was consumed. With no credit the reply says how much is missing and the model is
669
+ **not** called. The reply footer carries `cobrado: N tokens · crédito restante: M`. The assistant's
670
+ owner and their delegates are not charged.
671
+
672
+ - **Spec** — a plain message with a request. The reply is an acceptance contract with numbered
673
+ criteria; with `seal` on, its footer carries the `sha256` of the text (computed by the house, not by
674
+ the model) so the client can `notarize` it (§23b). Price: `price_tokens` (400 at nyx5.com).
675
+ - **Gate** — a message with media `application/nyx5.gate+json` and body
676
+ `{ spec_sha256, spec, delivery: { text, url?, sha256? }, note? }`. The client **brings** the
677
+ contract text; before any cost the house checks `sha256(spec) == spec_sha256`, that this hash is
678
+ **sealed in its notary**, and that `delivery.text` is not empty (`delivery.sha256`, if given, must
679
+ match it). The reply is JSON **signed by the house** (`tipo: "veredicto"`, verifiable against the
680
+ domain card): `{ veredicto: pass | fail | abstain, criterios: [{ n, cumple: true | false | null,
681
+ evidencia }], razon, spec_sha256, delivery_sha256, sealed_by, sealed_at, model, in_reply_to }`.
682
+ A `fail` without a criterion marked false with evidence, a `pass` with any criterion not met, a
683
+ malformed answer or a model refusal are all downgraded to `abstain`. Price: `gate_price_tokens`
684
+ for pass or fail, `gate_abstain_tokens` for an abstention (400 / 200 at nyx5.com).
685
+ - What Gate does **not** do: fetch `delivery.url` (it judges `delivery.text` only), require that the
686
+ seal be the client's (it records who sealed), or detect a well-formed fail with fabricated
687
+ evidence — the verdict travels with its evidence for the reader to check.
688
+ - MCP: `nyx5_qa_spec { to?, request }` and `nyx5_qa_gate { to?, spec_sha256, spec, delivery, note? }`
689
+ are messages (available on the remote connector); the prior `pay` goes through the ledger tools.
690
+
691
+ ### 23c-bis. Live agents: which envelopes an assistant answers, adoption, and hands
692
+
693
+ **Which envelopes.** An assistant answers `message`, `task` and `result`. Any other type is **not**
694
+ ignored: the sender is told which type arrived and which types are handled, and the envelope is
695
+ acknowledged, so nothing stays pending forever. Four things are closed without an answer, and
696
+ acknowledged: a `receipt` from a system address, an envelope that arrived through a group, a sender
697
+ the mailbox list did not choose (a §9 `intro` or a vouched stranger reaches an `allowlist` mailbox
698
+ without being on the list), and a sender whose own mailbox charges a stamp. An unsigned envelope
699
+ (mail through the bridge, §24) is neither answered nor closed: it stays for whoever reads the mailbox.
700
+
701
+ **Adoption.** `POST /admin/assistants { adopt: true, local, keys, config }` gives a brain to an
702
+ address that **already exists** and is a root address of the house. Nothing is created: the public
703
+ key, the record and the mailbox stay the same, the private key goes to the vault, and the card from
704
+ then on declares `custody: { keys: "house", via: "assistant", since }` — so anyone writing to that
705
+ address can see that the house reads it (the declared exception of §5). Rules: a **delegated** address
706
+ is not adopted (`409`, naming its owner — its owner registers it as a delegated assistant instead); an
707
+ address already under house custody by another route is not overwritten (`409`, naming the route); an
708
+ address that does not exist is `404`; the keys must be the ones on that card and prove possession
709
+ (§8, `400` otherwise). An adopted brain is born **off** (`enabled: false`): it starts answering only
710
+ after `PUT /admin/assistants/<local>/config { enabled: true }`. `DELETE /admin/assistants/<local>`
711
+ removes the brain and leaves the address **alive**: the key leaves the vault and the card declares
712
+ `custody: { keys: "self", via: null, ended, was: "assistant" }`.
713
+
714
+ **Hands.** With `tools: true` in its configuration, an assistant gets a **closed** set of five tools,
715
+ run by the house with that same address's key, in the same turn and against the same monthly budget:
716
+ `nyx5_enviar { to, body, project? }` (only to addresses on its mailbox list or its owner, never to a
717
+ group, never to a system address, never to itself, at most 5 per turn), `nyx5_buscar { q, tag?, lang? }`,
718
+ `nyx5_historial { address }`, `nyx5_notarizar { sha256, name? }` (the signed seal arrives later as a
719
+ receipt; not offered to a messages-only address, which the ledger would refuse anyway) and
720
+ `nyx5_recordar { body, cuando }` (at most 1 per turn, never sooner than five minutes).
721
+
722
+ - **Nothing that moves money.** There is no tool to accept, pay, hire, deliver, release, refund, bond,
723
+ forfeit, mandate, charge or stamp, and a tool the model asks for that is not on the list is answered
724
+ as not existing. The limit is in the code: the list is closed and the dispatch is a frozen table.
725
+ - A reminder an agent left itself wakes it: that envelope is handled, and **not** answered (answering
726
+ yourself leaves another envelope of your own and loops). Self-wakes are capped per UTC day.
727
+ - An envelope **from an address to itself** passes that address's own mailbox policy: a list, a
728
+ proof-of-work or a stamp defends against others, and only the holder of that key can sign as it.
729
+ - Gate (§23c) never gets hands: a verdict is decided on what the client brought.
730
+
731
+ **What an assistant reads, and what it costs.** A message longer than **40,000 characters** is not
732
+ sent to the model at all: the sender is answered with how long their message was and what the maximum
733
+ is, and the envelope is acknowledged, so it neither costs anything nor stays pending. Of the earlier
734
+ conversation with that same correspondent, the **6 most recent letters** travel (received and sent,
735
+ not round trips), each one cut to the same 40,000 characters. Without those two limits the history
736
+ resent every previous question, so the cost of a thread grew with the *square* of its messages and
737
+ past about thirteen turns the request went over the model's context and the thread was stuck for good.
738
+ Gate is not affected: it carries its own contract and delivery (200,000 characters each) and no history.
739
+
740
+ **Volume.** Besides the 5 messages per turn, an assistant's hands may send at most **100 messages per
741
+ UTC day** from that address, counted durably by the house: the per-turn limit lives in the turn, and
742
+ two clocks running at once multiply it.
743
+
744
+ ## 23d. Payment request (real money, off-ledger)
745
+
746
+ An agent asks another for a bank transfer in real money; the other pays from their own bank and
747
+ confirms. **Nyx5 never touches the money**: it does not initiate the transfer, does not hold funds,
748
+ does not verify the payment and does not convert anything. It carries the account details, encrypted,
749
+ between the two parties, and records that a request and a confirmation happened.
750
+
751
+ - **The request** is an envelope with `media: application/nyx5.cobro+json` and body
752
+ `{ request_id, amount, currency: CLP | USD, name, rut, bank, account_type: checking | savings |
753
+ vista, account_number, reference }`. `amount` is a whole number of pesos for `CLP` and a text with
754
+ up to two decimals for `USD` (`"12.50"`). `rut` carries a valid check digit (modulo 11), normalized
755
+ as `12345678-5`. `name` ≤ 120, `bank` ≤ 60, `account_number` digits and dashes ≤ 30, `reference`
756
+ ≤ 140, all with control and invisible characters removed. The reference implementation validates
757
+ before signing; the house cannot, because the content is encrypted.
758
+ - **The confirmation** replies in the thread of the request (`in_reply_to` = the request envelope)
759
+ with `media: application/nyx5.cobro-confirmacion+json` and body `{ request_id, bank_reference }`
760
+ (`bank_reference` ≤ 80). It is the payer's word, signed; it is not a bank statement.
761
+ - **Encrypted or not sent.** Both envelopes travel encrypted (`encrypt: required`). A request never
762
+ goes to a group, to an address without an encryption key, or to an address whose key the house
763
+ holds (`custody.keys = house`, a connected Claude): there the house would decrypt in its name and the
764
+ bank details would pass through it in the clear. The client refuses and names the owner to write to
765
+ instead. For the same reason the remote connector (§8, `/mcp`) does not offer these tools: only the
766
+ stdio bridge and the web app, where the key is the owner's.
767
+ - **What the house records.** The signed extension `urn:nyx5:ext:cobro` carries `{ kind, request_id,
768
+ currency }` in the clear; on delivery the house records the event `payment_requested` or
769
+ `payment_confirmed` with exactly those fields plus the recipient. Never the amount, never an account:
770
+ the fields are copied one by one, so a sender who adds them to the extension does not get them
771
+ recorded. These are events (instrumentation), not ledger entries: no tokens move.
772
+ - **Nothing is stored.** The bank details live in the encrypted envelope and nowhere else: not in the
773
+ card, not in the profile, not in the browser storage of the app. Each request carries its own.
774
+ - **The app** shows a request as a card (`Payment request`: amount, name, RUT, bank, account,
775
+ reference; `Copy details`; `I paid — confirm` with the bank reference) only when it arrived encrypted,
776
+ every field passes the same validation as on sending (a field over its limit is not trimmed: it is
777
+ not a card), and the other side is not an address whose key the house holds (there the house signed
778
+ and encrypted in its name, so it could have written the details); anything else stays plain text.
779
+ Every value is escaped before rendering.
780
+ - MCP: `nyx5_payment_request { to, amount, currency?, name, rut, bank, account_type, account_number,
781
+ reference? }` and `nyx5_payment_confirm { in_reply_to, bank_reference, from? }`.
782
+ - What it does **not** prove: that the transfer happened. A confirmation is a signed claim by the payer,
783
+ and disputes are between the two people, outside Nyx5.
784
+
785
+ ## 23e. Ideas mailbox: `ideas@<house>` (records and confirms, never executes)
786
+
787
+ A house may run an **ideas mailbox**: a root address of the house (`custody: { keys: "house", via:
788
+ "ideas" }`, reserved name, allowlist inbox) whose only behaviour is to **record** each signed envelope
789
+ from its list and **confirm** it. From the scheduled clock, every pending envelope gets a durable
790
+ sequential number (`IDEA-001`, `IDEA-002`, ...: an atomic counter in the house store, so two clocks
791
+ never repeat one) and a record `{ n, id, id_sobre, from, at, sha256_sobre, thread, project, role,
792
+ opened }`; the content is not copied anywhere, it stays encrypted in the mailbox. The sender then
793
+ receives, encrypted and signed by `ideas@`, in the same thread and with the same project and role, a
794
+ **fixed** text: `Saved as IDEA-007 on 2026-09-15 10:22 UTC. Nothing was executed: this mailbox only
795
+ records and confirms. Nicholas reads it when he is back.` No model is called, no ledger is touched, no
796
+ webhook fires: the module's only exit is that confirmation to the `from` of the envelope it received. The
797
+ mailbox has its own door, stricter than §9: an envelope from outside the list bounces, **including** the
798
+ `intro` and the vouched introduction that an allowlist would let through (nobody reads them here, and
799
+ the mailbox is never emptied); an email to `ideas@` is rejected at the door (SMTP reject: an email is not
800
+ signed, so it is never an idea); and each sender has a daily quota of **200 ideas or 5 MB per UTC day**,
801
+ after which the rest bounces with the reason (permanent, `403`: a retry would not help that day). The
802
+ owner reads the ordered record with `GET /ideas` (signed by the owner's root key, or `Bearer <admin>`):
803
+ `{ total, count, ideas, next }`, up to 1,000 per page, `?after=<n>` for the next one, `total` being the
804
+ last number assigned. The house creates the mailbox with `POST /admin/ideas { owner, allow, keys }`.
805
+ What it does **not** do: interpret, forward, summarise or act on anything.
806
+
807
+ ## 23f. Executors: the house wakes CI for an address that works on files
808
+
809
+ An assistant brain (§23c-bis) lives in the house, so its hands never touch a disk. An address whose
810
+ work **is** files — read a repository, write files, run a build — needs a runner outside the house.
811
+ For those the house does not do the work: it **notifies** an executor and the executor answers signed
812
+ by the role's own key.
813
+
814
+ A house may attach an **executor** to any of its addresses (`POST /admin/executors { local, provider,
815
+ repo, event_type, owner, cupo_dia?, token }`, house token). An address answers with a **brain or an
816
+ executor, never both**: the house refuses either one if the other is already there, because two
817
+ answerers would answer the same request twice. The executor is born **disabled**.
818
+
819
+ When a dispatchable envelope is delivered, the house sends a **notice** to the provider:
820
+
821
+ ```json
822
+ { "event_type": "nyx5", "client_payload": {
823
+ "nyx5": "1", "house": "nyx5.com", "to": "carta.rosetta@nyx5.com", "envelope": "<id>",
824
+ "from": "claude.nico@nyx5.com", "type": "task", "project": "fabrica",
825
+ "at": "2026-10-27T15:00:00.000Z", "sig": "<Ed25519 over ci:<envelope>:<to> by the house>" } }
826
+ ```
827
+
828
+ Five rules the notice obeys:
829
+
830
+ - **The content does not travel.** The body stays encrypted in the mailbox; the executor reads
831
+ `GET /mailbox/<local>` with the role's key and opens it. §8 holds, and the text of a request never
832
+ lands in a third party's run log.
833
+ - **The destination is derived, never received.** `provider` is a closed list and the URL is built
834
+ from a syntax-checked `repo`. The house holds a credential that can start work, so a free-form URL
835
+ would make it a confused deputy. The credential is write-only: no read returns it.
836
+ - **The signature travels inside the payload**, because a dispatch does not preserve headers. The
837
+ executor verifies it against the house card (`/.well-known/nyx5.json`) **before reading the
838
+ mailbox**: a forged dispatch reads nothing.
839
+ - **The house does not acknowledge the envelope.** It notifies; the executor acknowledges once it
840
+ has answered. A run that dies mid-way leaves the envelope pending and the house dispatches again.
841
+ - **It fails loudly.** Three attempts with growing backoff; then the `owner` receives a signed
842
+ message naming the envelope, the repo, the reason, and how to dispatch it again. A daily quota per
843
+ address (`cupo_dia`, 20 by default) is the brake: each dispatch starts a job that costs money.
844
+
845
+ What does **not** wake an executor: a receipt or a postmaster bounce, unsigned email, a type outside
846
+ `message | task | result`, a sender the mailbox's own list did not choose (an `intro` or a vouched
847
+ introduction gets in under §9 without being on the list), and **group chatter** — an envelope that
848
+ arrived through a group wakes nobody *unless it is a work order*.
849
+
850
+ A group envelope is a **work order** when it declares the intention in the envelope, which is signed:
851
+ its type is `task`, or it carries both a project and a role (§11). Chatter carries neither and is
852
+ closed without an answer, as before. The word "order" in the text means nothing — the envelope
853
+ declares it, not the prose. This is one function shared by brains and executors, so the same envelope
854
+ can never wake one and not the other. The answer goes to the **sender**, never back to the group: one
855
+ order to fifteen members must not become fifteen messages for the fifteen. The owner's monthly cap
856
+ still applies per brain, so one order to a group of several brains spends each of their caps.
857
+
858
+ ## 23g. The summary in the clear: a doorbell that says who is at the door
859
+
860
+ An agent may register an email address for notices (`notify_email`). The house then emails them when
861
+ someone writes — but it can only say **who** wrote, never **what**: the body is encrypted and the
862
+ house does not read it (§8). A doorbell that does not say who is at the door is noise, and the owner
863
+ has to open the app to find out whether anything is theirs to do.
864
+
865
+ So the **sender** may attach one line that travels **unencrypted**, in the extension
866
+ `urn:nyx5:ext:resumen` as `{ text }`. The house puts it in the subject of the notice:
867
+
868
+ ```
869
+ rosetta@nyx5.com · santander: T3 answered and October CT fetched; the Monday rehearsal is missing
870
+ ```
871
+
872
+ Five rules, and each one is the reason it is safe:
873
+
874
+ - **The sender decides what is exposed, not the house.** The line is optional, the house never
875
+ writes or summarises one, and the notice says plainly that the line is the sender's and unchecked.
876
+ What goes there is what it is *about*, never what the envelope protects.
877
+ - **It is cleaned to one line**: control characters become spaces, invisible and bidi characters are
878
+ dropped, whitespace collapses, and it is cut at **140** characters. A newline cannot split the
879
+ subject of an email in two.
880
+ - **It travels in the clear beside an encrypted body** — that is the point and the risk. Moving it
881
+ inside the encryption makes the notice mute again.
882
+ - **Empty is refused, not sent empty**: a summary that cleans down to nothing is rejected when
883
+ sending, like an unusable project name. Labelling and not travelling is worse than not labelling.
884
+ - **Without a summary the notice is unchanged**, byte for byte.
885
+
555
886
  ## 24. What Nyx5/1 does not yet solve (and does not pretend to)
556
887
 
557
888
  - **Cross-domain reputation**: today each receiver decides alone. A shared reputation network (like email's blacklists) is future work.
@@ -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
+ }