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
- Lease a name.
POST https:/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./ bind.ws/ lease - Claim it. Send the
claimmethod (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;resetrulesrestores the defaults, orapplypresetsets a bundle. - Publish and query.
POST /with an event,events POST /orquery POST /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.count - Bring history in.
backfillpulls your own events from the relays in your kind 10002 stored here, or from a list you give.pullfromcopies another relay.listjobsandpullstatusreport on them. - Read the summary.
GET /is the machine-readable state of a name: owner, rules, fuel, member count when public and the group address.card.json GET /is the same, signed by the relay's key.card.nostr - Hand it over.
transferownergives 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 / is every member's name and picture in one signed record, GET / is where those members also publish, and GET / 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 / answers { event_id, accepted, message }; POST / answers a list of events; POST / 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/ 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 andconverted: truewhen 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 withavailable, 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'sseqfor 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/; a pattern that does not compile fails the call withpatterns/ 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; returnstrue. The same member quota and depth apply.deleteclaim code: revoke a code from the shared invite store; returnstrue. 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.viewsis a map of view name tooff,write,hourlyordaily(trueandfalsestill mean the default trigger and off).featuresis a map:searchisprose,fulloroff;sync,count,discovery,names,files,pagesandsignerare 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;searchneeds a source,articlestakes 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 withinvalid: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/. Passbackups/ id { "id": "name", "includeGit": false }tobackupnowwhen Git is too large for the bounded archive; recover large repositories separately withgit clone --mirrororgit bundle. POST the exact archive with a payload-bound NIP-98 signature to/, then separately signbackups/ preview /on a fresh target. Both require the archived owner key. Archives are capped at 8 MiB and 12,000 entries.backups/ restore listlisthistory: private older list versions belonging to the authenticated owner, moderator or member.restorelist eventId: an unsigneddraftanddiff(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 (formatbind.ws/). A section the document leaves out is left alone. Withrelay-config/ 2 dryRun, the answer is{ changes, warnings }: what applying would change, section by section with asummaryline 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?}: apullorpush, once or every 1, 6 or 24 hours. Up to 10 relays; filters take up to 50 authors and 50 kinds and asince.removejob id,runjob id,listjobs. Pull jobs exposepullSourceswhile running andlast.sourcesafter 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 withsetpolicy {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/: 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:/; 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 /reports the meters.fuel - A relay only pulls from sources that let anyone read, and only pushes to targets that accept its key's writes.