clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

dependabot/github_actions/actions-9b61906d8b
bindws / docs / 13-scripts-and-agents.md
13-scripts-and-agents.md 17.9K source

title: Scripts and agents audience: integrator

Scripts and agents

A relay for a script, an agent or a service, from nothing to handover, without a browser. Everything the console does is a signed HTTP call, so a key is all a program needs.

The path

  1. Lease a name. POST https://bind.ws/lease answers with a memorable name, its websocket URL, its console URL and when it expires (14 days). Anyone can read and write to a lease. Sign the request with your key (NIP-98, below) and the lease is reserved: only that key can claim it.
  2. Claim it. Send the claim method (NIP-86, below) to the relay's console URL. The relay converts in place: the events stay, your key is the owner. A lease starts with open writes and a 14-day keep-for rule; resetrules restores the defaults, or applypreset sets a bundle.
  3. Publish and query. POST /events with an event, POST /query or POST /count with a list of filters, each signed with NIP-98. The signer has the same standing as an authenticated socket, so the owner's own reads pass any read rule.
  4. Bring history in. backfill pulls your own events from the relays in your kind 10002 stored here, or from a list you give. pullfrom copies another relay. listjobs and pullstatus report on them.
  5. Read the summary. GET /card.json is the machine-readable state of a name: owner, rules, fuel, member count when public and the group address. GET /card.nostr is the same, signed by the relay's key.
  6. Hand it over. transferowner gives the relay to a member, so an agent can set a relay up and pass it to its human. The old owner stays on as a moderator.

The cheap way to learn a community is its views: GET /view/profiles is every member's name and picture in one signed record, GET /view/relays is where those members also publish, and GET /view/zaps says what the place values. Each is one request and no websocket; the relay's information document lists which views it keeps.

Exercise a relay network in production

npm run test:network runs a finite, manually invoked production exercise. It creates five small relays, gives run-owned identities, records the topology, exercises the relationships, extracts a report and deletes the relays it created. It has no timer, workflow or standing job outside the run.

The topology is deliberately small and represents different data-flow shapes:

Role Relationship Activity and size assumption
peer-a, peer-b equal peers, each pulls from the other short bursts, idle catch-up and repeat sync; both are small
satellite pulls selected public profiles from relay.damus.io and nos.lol a small node samples two much larger external sources
personal exchanges selected notes with the peers, sends public tasks to hub and pulls results back low-volume personal activity
hub receives tasks and publishes results for personal; pulls selected profile context from satellite a small agent-facing relay

The task and result path is a public relay workflow. It does not represent confidential agent computation. The encrypted kind-4 fixture stays on the personal relay, and the exercise checks that it is not copied to the hub. External sampling is read-only and bounded to three sampled profile authors per source and one retained kind-0 profile per author. Each sync job has a 120-second wait budget; the run has a 20-minute scenario budget, with cleanup allowed to finish afterward. A public source can be empty, unavailable or rate limited, so its result is reported as observed or inconclusive rather than treated as a capacity claim. The two external relays are treated as larger than this network; their size is not measured.

Start with an offline plan, then run the exercise. The default output directory is private state under the user's local application data directory. A supplied directory must be new and is created with mode 700.

npm run test:network -- plan
npm run test:network -- run [new-output-directory] [domain]
npm run test:network -- cleanup <output-directory>

The run writes a private manifest.json and report.json in that directory; the manifest contains the run keys and is mode 600, so it must not be committed or shared. The manifest is written before the first claim and after each state change. The finally cleanup extracts final stats and storage inventory, then verifies each relay's owner before calling deleterelay. If a process loses a claim reply or stops before cleanup, run cleanup against the same directory after checking that the manifest is the intended run. Cleanup only follows the generated names and matching owners, and reports any relay it could not delete. Baseline fuel snapshots follow provisioning; final snapshots precede deletion. Their deltas cover the exercise and observation calls, excluding provisioning and teardown. The full final counters also contain provisioning usage. These are tenant meters, not a provider invoice. The report includes signed synthetic fixtures, job results, per-source coverage and operation-lock retry counts. A members-only source refuses an unauthenticated pull; membership alone does not supply a job with credentials.

The checks cover bidirectional peer convergence, deduplication, idle catch-up, filtered task and result flow, private-event isolation, unauthorized writes and management, a refused members-only pull, and separation from external profile traffic. One-shot server jobs are removed after each check. The exercise is a bounded correctness and relationship test, not a load test or evidence of large-relay capacity.

Signing a request (NIP-98)

Every management call and every door that needs a key takes an Authorization: Nostr <base64 event> header. The event is:

Field Value
kind 27235
created_at now; the relay allows 60 seconds either way
tag u the full URL, host and path must match the request
tag method the HTTP method
tag payload the hex SHA-256 of the body, required whenever there is a body

Encode the signed event as base64 JSON. The relay answers 401 with an auth-required: reason when any of that is off.

With curl and nak:

BODY='{"method":"claim","params":[]}'
TOKEN=$(nak event -k 27235 -t u=https://brave-otter.bind.ws/ -t method=POST \
  -t payload=$(printf '%s' "$BODY" | shasum -a 256 | cut -d' ' -f1) --sec "$NSEC" | base64)
curl -X POST https://brave-otter.bind.ws/ \
  -H "Authorization: Nostr $TOKEN" -H "content-type: application/nostr+json+rpc" -d "$BODY"

With node and nostr-tools:

import { finalizeEvent, generateSecretKey } from "nostr-tools/pure";
import { getToken } from "nostr-tools/nip98";

const sk = generateSecretKey();
const sign = (e) => finalizeEvent(e, sk);

// A reserved lease: the signature names the key that may claim it.
const leaseURL = "https://bind.ws/lease";
const lease = await (await fetch(leaseURL, { method: "POST", headers: { authorization: await getToken(leaseURL, "POST", sign, true) } })).json();

// Management calls go to the relay's root as JSON-RPC.
async function rpc(method, ...params) {
  const body = { method, params };
  const token = await getToken(lease.console, "POST", sign, true, body);
  const r = await fetch(lease.console, { method: "POST", headers: { authorization: token, "content-type": "application/nostr+json+rpc" }, body: JSON.stringify(body) });
  return r.json();
}

await rpc("claim");                       // { result: { owner, claimed: true, converted: true } }
await rpc("applypreset", "outbox");       // one call sets writes, reads, kinds and keep-for
await rpc("backfill", ["wss://relay.damus.io", "wss://nos.lol"]);
console.log(await rpc("listjobs"));
console.log(await rpc("gitstorage", "<owner hex pubkey>", "my-project"));

The bridge takes the same header. POST /events answers { event_id, accepted, message }; POST /query answers a list of events; POST /count answers { count }. The bridge is rate limited per address at four times the per-connection allowance and answers 429 past it.

Management calls (NIP-86)

POST / with content-type: application/nostr+json+rpc and a body of { "method": "...", "params": [...] }. Answers are { result } or { error } with a reason that starts with invalid:, restricted:, unsupported: or error:. Each method needs an action; the owner has every action and a moderator has read, members, ban, delete events, invites and reports. supportedmethods lists the names.

Open to anyone

  • claim: take an unclaimed relay, or convert a lease you hold. Answers the owner and converted: true when it was a lease.

Read

  • stats: counts, connections, name, owner and fuel.
  • getpolicy: the whole policy.
  • listpresets: preset names, descriptions, whether one needs a source, and the Connect fold's shortcuts a preset sets when it has them.
  • listconnectiontemplates: the Connect fold's library, each template with available, whether the feature it needs is on.
  • listconnections: the owner's shortcut list, or the defaults while there is none.
  • listmembers, listpeople, listallowedpubkeys: the member list in three shapes.
  • listbannedpubkeys, listbannedevents, listblockedips: the bans.
  • listinvites: detailed invite records, including expired and exhausted entries; a member under the invite rule sees only their own.
  • listclaims: up to 200 usable invite code strings; a member under the invite rule sees only their own. See NIP-86 membership claims for the pinned proposal and constraints.
  • listrecentevents [limit]: the newest events.
  • listallowedkinds, listblockedkinds, listretention: the kind and keep-for rules.
  • listblobs, listreports: files and the reports queue.
  • listaudit [before]: the moderation log, newest first, 100 rows; pass the last row's seq for the next page. Every method that changed something is in it with the caller, the first string parameter as the target and the rest as detail.
  • successionstatus: last seen, days silent, warning state and the handover log; the heir may read it too.

Members

  • setmember pubkey {name?, note?, role?, keepDays?, maxBytes?}: add or edit. Only the owner sets roles and limits.
  • allowpubkey pubkey reason: the NIP-86 spelling of add.
  • removemember pubkey, unrulepubkey pubkey: remove a member or lift a ban.
  • removesubtree pubkey: remove a member and everyone they invited, stopping at moderators.

Ban

  • banpubkey pubkey reason, banevent id reason, allowevent id.
  • blockip address reason, unblockip address.
  • setblockedwords [words]: the list, plain words or /patterns/; a pattern that does not compile fails the call with invalid:. setpolicy {blockedWordsInTags: true} searches tag values too.

Delete events

  • deleteevent id: remove one event.

Invites

  • createinvite ttlSeconds maxUses note: mint a code. A member under the invite rule may mint within their quota.
  • revokeinvite code.
  • createclaim code: mint a chosen code with the default three-day lifetime and unlimited uses; returns true. The same member quota and depth apply.
  • deleteclaim code: revoke a code from the shared invite store; returns true. Members may only delete their own codes.

Reports

  • resolvereport id action: dismiss, delete or ban.
  • listeventsneedingmoderation: one entry per open reported thing, event id or blob hash.

Rules

  • setpolicy {…}: any policy fields; the relay drops values it does not accept. views is a map of view name to off, write, hourly or daily (true and false still mean the default trigger and off). features is a map: search is prose, full or off; sync, count, discovery, names, files, pages and signer are booleans (see Relay configuration, Rules).
  • allowkind k, disallowkind k, unrulekind k.
  • setretention kind|null days, purgekind kind|null days.
  • resetrules: the defaults, kind rules and keep-for cleared.
  • applypreset name {source?}: a bundle; search needs a source, articles takes one.
  • setconnections [list]: the Connect fold's shortcuts, the whole list in the order shown, each {template, visibility?, title?, about?, inputs?}, up to 24; an entry that does not fit fails the call with invalid: and the reason. See Connection templates.

Identity

  • changerelayname, changerelaydescription, changerelayicon: one string each.
  • adddomain host, checkdomain host, removedomain host, listdomains: custom hostnames, when the host has them on.
  • notifytest: a test message to the owner.

Storage

  • storagestats: bytes by kind, files, retention.
  • gitstorage owner identifier: an owner-only inventory of one accepted GRASP repository. The result reports compressed and raw Git object bytes, metadata bytes, ref and receipt counts, and the relay's shared physical SQLite size. The byte breakdown is diagnostic, not another fuel charge. It never deletes data.
  • deleteblob sha256.
  • listdumps, dumpnow, deletedump name.
  • backupnow [id], listbackups, deletebackup id: portable archives; owner NIP-98 downloads use /backups/id. Pass { "id": "name", "includeGit": false } to backupnow when Git is too large for the bounded archive; recover large repositories separately with git clone --mirror or git bundle. POST the exact archive with a payload-bound NIP-98 signature to /backups/preview, then separately sign /backups/restore on a fresh target. Both require the archived owner key. Archives are capped at 8 MiB and 12,000 entries.
  • listlisthistory: private older list versions belonging to the authenticated owner, moderator or member.
  • restorelist eventId: an unsigned draft and diff (added tags, removed tags, content changed) for one saved version belonging to that signer. Sign the draft in the client and publish it normally.

Config

  • exportconfig, importconfig document [{dryRun: true}]: rules, identity, members, bans, address blocks, kind rules, retention and the Connect fold's shortcuts as one document (format bind.ws/relay-config/2). A section the document leaves out is left alone. With dryRun, the answer is { changes, warnings }: what applying would change, section by section with a summary line each, and the entries the relay would not take; nothing is applied or logged.

Jobs

  • pullfrom url, pullstatus: copy one relay and follow it.
  • addjob {kind, relays, filter?, every?, label?}: a pull or push, once or every 1, 6 or 24 hours. Up to 10 relays; filters take up to 50 authors and 50 kinds and a since.
  • removejob id, runjob id, listjobs. Pull jobs expose pullSources while running and last.sources after finishing: each source carries its URL, mode, status, stored/skipped/blob counts, retry count, error and coverage warning. Query window progress is persisted with the job.
  • deliverystatus: recent automatic NIP-65 per-event target status, attempts and last error. Enable with setpolicy {delivery: {enabled: true, maxTargets: 8}}.
  • backfill [relays?]: your own events from your kind 10002 here, or from the list.

Transfer

  • transferowner pubkey: to a member; you stay as a moderator.
  • setsuccession {heir, afterDays} with 90, 180 or 365, clearsuccession.

Fork

  • forkrelay {name?, holder?, filter?, people?}: a new leased name filled from this relay, reserved for the holder. One an hour.

Delete

  • deleterelay name: the relay's own name, typed, is the confirmation.

gitstorage is a diagnostic read, not a billing ledger or a deletion grant. Its operation counts do not add a tenant charge, and its 60-second cooldown is per live Durable Object instance, so eviction or restart resets it.

The configuration file

exportconfig is a document, and a document can live in a repository. The format is bind.ws/relay-config/2: policy, members, bans, addresses, banned_events, kinds, retention and connections, each a section, and a section the file leaves out is left alone when it is applied, so a file may carry the rules and nothing about people. connections is the Connect fold's shortcuts, in order, each naming a template from the repository's connection-templates/ folder with who sees it and the template's inputs (Connection templates). The schema is at https://bind.ws/relay-config.schema.json; put it in $schema and the editor checks the file as you type. JSONC is fine: comments and trailing commas.

Four verbs, run from a checkout of the repository with RELAY_SK holding the owner's key as hex or nsec:

npm run relay check relay.jsonc                      # the schema and the relay's own parser, offline
npm run relay plan  relay.jsonc wss://name.bind.ws   # what applying would change; touches nothing
npm run relay push  relay.jsonc wss://name.bind.ws   # the plan, then the change
npm run relay pull  wss://name.bind.ws relay.json    # the relay as it is, into a file

check fails on anything a relay would drop, since a silently dropped entry is a mistake in a file under source control. plan is importconfig with { dryRun: true }: the answer lists the changes section by section with a summary line each, plus the entries the relay would not take. pull then git diff shows drift between the file and the relay.

The templates in the repository's relay-templates/ folder are files in this format with a template block for the title and the blurb; the console offers them as presets. A template is a good start for a file of your own.

Limits worth knowing

  • Leases: five a minute per address and 60 a minute overall at the apex.
  • Bridge and sockets: the relay's events and queries per minute, per connection, and four times that per address.
  • Fuel: a lease cannot be zapped and runs on the free allowance only. A claimed relay past an allowance with no balance goes read-only until zapped; GET /fuel reports the meters.
  • A relay only pulls from sources that let anyone read, and only pushes to targets that accept its key's writes.