4A v0.5 — Audiences runbook
Audience: Operators of an api.4a4.ai deployment (or anyone running the
gateway against their own Cloudflare account).
Scope: Setup, the seven /v0/audience/* routes, the inbox endpoint, the
NIP-05 fa extension, the eight MCP tools, the 4a audience CLI verbs, and
troubleshooting.
Status: Ready-to-run for v0.5. The §10 worked-example walkthrough is ready-to-run, needs Evan to execute — this build session does not have outbound websocket access to the relay set, so the live-relay round-trip is deferred to the operator.
1. Setup
1.1 Prerequisites
- Node ≥ 20, Cloudflare Wrangler ≥ 4.85, an AWS account with a KMS HMAC key
for derivation (the same one Phase 2 already uses for
nostr_privderivation). - A 4A bearer JWT in
$FOUR_A_JWT. Obtain one athttps://api.4a4.ai/auth/github/start. The CLI and curl examples below all requireAuthorization: Bearer $FOUR_A_JWT. - For the worked example: one Cloudflare Pages project routed to
claim.4a4.ai(DNS pointing at<project>.pages.devvia CNAME).
1.2 Sign in
The custodial OAuth path Phase 2 already ships covers v0.5 unchanged. Visit
https://4a4.ai, click Continue with GitHub, and the gateway:
- Receives the OAuth callback.
- Constructs
oauth_id_string = "github:<id>", runsKMS.GenerateMac(MacAlgorithm=HMAC_SHA_256)to derive 32 bytes, clamps to a valid secp256k1 scalar, computes the schnorr pubkey. - Mints a 24-hour HS256 JWT carrying
{sub, pub}and sets it as a cookie. - Discards the priv from memory.
Use that JWT verbatim as $FOUR_A_JWT for everything below.
1.3 The two key types
Audience identity (aud_id) and per-epoch (aud_epoch_n) keypairs are
audience-scoped, not user-scoped — they're not KMS-derived. The gateway
generates them inside /v0/audience/create and /v0/audience/rotate and
returns them in the response body. The default behavior is non-custodial:
the gateway does NOT persist these privs. Operator stores them; clients pass
them back on subsequent calls.
A future revision may add ?delegate=true to opt into gateway-side custody
(see PLAN-v0.5 §6 Q1) — not in v0.5.
2. Creating an audience
2.1 curl
curl -sS -X POST https://api.4a4.ai/v0/audience/create \
-H "Authorization: Bearer $FOUR_A_JWT" \
-H "Content-Type: application/json" \
-d '{
"slug": "team-design",
"name": "team-design",
"description": "Design notes shared with Allison."
}'
Successful response:
{
"ok": true,
"audience_address": "30520:<aud_id_pub>:team-design",
"aud_id_pub": "<32-byte hex>",
"aud_id_priv": "<32-byte hex>",
"epoch": 1,
"aud_epoch_pub": "<32-byte hex>",
"aud_epoch_priv": "<32-byte hex>",
"declaration_event_id": "<64-byte hex>",
"founding_grant_event_id": "<64-byte hex>",
"founder_pubkey": "<your custodial nostr_pub>",
"founder_npub": "npub1...",
"relay_acks": { "declaration": [...], "founding_grant": [...] }
}
Save aud_id_priv and aud_epoch_priv somewhere safe. Lose them and the
audience is unrecoverable.
2.2 CLI
4a audience create --slug team-design --name team-design \
--description "Design notes shared with Allison."
2.3 MCP
// MCP tools/call payload
{
"name": "audience_create",
"arguments": {
"slug": "team-design",
"name": "team-design",
"description": "Design notes shared with Allison."
}
}
3. Inviting (single-paste UX, four input types)
/v0/audience/invite only handles the email + blank cases — when identity
isn't already known. The handle and npub cases route through
/v0/audience/grant directly (no claim flow).
3.1 Email or blank → claimable URL
curl -sS -X POST https://api.4a4.ai/v0/audience/invite \
-H "Authorization: Bearer $FOUR_A_JWT" \
-H "Content-Type: application/json" \
-d "{
\"audience_address\": \"$AUDIENCE_ADDRESS\",
\"aud_id_priv\": \"$AUD_ID_PRIV\",
\"ttl_seconds\": 604800
}"
Returns:
{
"ok": true,
"four_a_url": "4a://invite/team-design/1?k=4ainv1...",
"https_url": "https://claim.4a4.ai/invite/team-design/1?k=4ainv1...",
"invite_pub": "<32-byte hex>",
"invite_priv_4ainv": "4ainv1...",
"expires_at": 1893456000,
"declaration_event_id": "<64-byte hex>"
}
Paste either URL into email/Slack/iMessage. The 4a:// form opens any local
4A client that registered the scheme; the HTTPS form falls back to the static
claim page at claim.4a4.ai.
3.2 Handle or npub → direct grant
# Handle: GET https://<host>/.well-known/nostr.json?name=<name> first to
# resolve the pubkey, then:
curl -sS -X POST https://api.4a4.ai/v0/audience/grant \
-H "Authorization: Bearer $FOUR_A_JWT" \
-H "Content-Type: application/json" \
-d "{
\"audience_address\": \"$AUDIENCE_ADDRESS\",
\"aud_id_priv\": \"$AUD_ID_PRIV\",
\"aud_epoch_priv\": \"$AUD_EPOCH_PRIV\",
\"recipient_pubkey\": \"$RECIPIENT_PUB\"
}"
/grant issues a kind:30521 directly to the recipient (NIP-44-encrypted from
your KMS-derived identity to the recipient pub) and republishes the
declaration with the new member added. No epoch rotation — adding members
exposes no historical content per PLAN-v0.5 §6 Q2 default. The declaration
is published first and the grant second, so readers never see a grant to a
non-member.
3.3 CLI
4a audience invite --address $AUDIENCE_ADDRESS --aud-id-priv $AUD_ID_PRIV
4a audience grant --address $AUDIENCE_ADDRESS --aud-id-priv $AUD_ID_PRIV \
--aud-epoch-priv $AUD_EPOCH_PRIV --recipient $RECIPIENT_PUB
3.4 Raw (non-custodial) grant to a new member
POST /v0/audience/raw/grant (NIP-98) takes a caller-signed grant and an
optional aud_id-signed updated_declaration. To grant a pubkey that is not yet
a member or pending invite, send an updated_declaration that adds it. The
gateway accepts it only as a membership-adding successor of the current
declaration, and rejects it with 400 otherwise:
- signed by aud_id; a valid
kind:30520; - same slug, same
fa:epochandfa:epoch-pubkey(key changes go through/raw/rotate); - members are a superset of the current members (removal goes through
/raw/rotate); - not a close (
fa:status=closed; lifecycle changes go through/raw/publish-declaration); created_atstrictly newer than the current declaration (re-sending the identical current event is a no-op).
The grant is then validated against the updated roster, but the granter must already be a member of the CURRENT declaration (or the audience identity): an updated declaration can add recipients, never promote the granter. The declaration is published first. If no relay accepts it, the call returns 502 and the grant is not published.
4. Publishing
curl -sS -X POST https://api.4a4.ai/v0/audience/publish \
-H "Authorization: Bearer $FOUR_A_JWT" \
-H "Content-Type: application/json" \
-d "{
\"audience_address\": \"$AUDIENCE_ADDRESS\",
\"aud_epoch_pub\": \"$AUD_EPOCH_PUB\",
\"kind\": 30510,
\"d_tag\": \"team-design-css-reset\",
\"alt\": \"encrypted Observation in team-design\",
\"payload\": {
\"@context\": \"https://4a4.ai/ns/v0\",
\"@type\": \"Observation\",
\"observationAbout\": { \"@id\": \"https://example.org/css-resets\" },
\"measuredProperty\": \"css-trick\",
\"value\": \"Saw a slick CSS reset that drops margin: 0 on body…\"
}
}"
The route:
- NIP-44-encrypts
payload(JSON-stringified) toaud_epoch_pub. - Builds the
kind:30510-30514rumor witha,fa:epoch, onepper current member, andblake3of the ciphertext. - For each current member, builds a NIP-17 seal + gift-wrap (fresh ephemeral key per recipient), publishes the wrap.
- Returns the rumor event id and per-recipient gift-wrap acks.
Publisher MUST be a current member of the audience (verified server-side).
4.1 Five payload kinds
kind |
Inner payload type | Public counterpart |
|---|---|---|
| 30510 | Observation | 30500 |
| 30511 | Claim | 30501 |
| 30512 | Entity | 30502 |
| 30513 | Relation | 30503 |
| 30514 | Commons | 30504 |
The inner payload shape is the v0 JSON-LD shape unchanged — see SPEC.md.
5. Reading (the inbox)
curl -sS "https://api.4a4.ai/v0/audience/team-design/inbox?limit=50" \
-H "Authorization: Bearer $FOUR_A_JWT"
Returns:
{
"ok": true,
"audience_slug": "team-design",
"reader_pubkey": "<your nostr_pub>",
"since": null, "limit": 50,
"items": [
{
"event_id": "<rumor id>", "received_at": 1777344425, "kind": 30510,
"audience_slug": "team-design", "epoch": 2,
"publisher_pubkey": "<publisher pub>",
"created_at": 1777344420,
"payload": { ...decrypted JSON-LD... },
"d_tag": "team-design-css-reset"
}
],
"next_since": 1777344425,
"next_cursor": "001777344425:<wrap id>",
"has_more": false
}
Paging. The inbox pages on the server-receive axis: when the gateway
received each gift-wrap, NOT the rumor's created_at or the wrap's
NIP-59-jittered created_at. Each item carries that received_at (unix
seconds). To fetch what's new, pass back one of:
?cursor=<next_cursor>: exact, exclusive resume after the last wrap the previous call examined. No duplicates, and no stall when many wraps share one second. Preferred.?since=<next_since>: inclusive (received_at >= since), so the boundary wrap comes back again. Dedupe byevent_id.
has_more: true means there may be more wraps past the cursor. Keep paging
until it is false. A call examines at most limit × 4 wraps: wraps for
other audiences, or ones that can't be decrypted, are skipped but still move
the cursor, so a page can hold fewer than limit items (even zero) while
has_more is true. Items within a page are sorted by created_at.
The route runs the §2.5 capability-based decryption pipeline entirely inside the per-request KMS-derivation window — your priv lives only on the request stack and is zeroed at the end. No keys persist.
5.1 Local-client alternative
If you'd rather not delegate decryption to the gateway, skip the inbox
endpoint. Subscribe to kinds:[1059], #p:[<your-pub>] on your relay set,
NIP-17-unwrap, look up the matching kind:30521, decrypt locally. The Sonata
plugin and the 4a CLI both ship this code path.
4a audience inbox --slug team-design --limit 50
6. Rotating
curl -sS -X POST https://api.4a4.ai/v0/audience/rotate \
-H "Authorization: Bearer $FOUR_A_JWT" \
-H "Content-Type: application/json" \
-d "{
\"audience_address\": \"$AUDIENCE_ADDRESS\",
\"aud_id_priv\": \"$AUD_ID_PRIV\",
\"add_members\": [],
\"remove_members\": [\"$EX_MEMBER_PUB\"],
\"remove_pending\": []
}"
Response includes the new epoch, the new aud_epoch_pub + aud_epoch_priv,
the rebuilt declaration event id, and a per-recipient grant manifest.
When to rotate: on member removal (so the ex-member can't read new content) and periodically as a hygiene refresh (PLAN-v0.5 §6 Q2 default). Rotation on member add is unnecessary — adding a member exposes no historical content because they only get the current epoch key, not past keys.
6.1 Polled claim-watcher
When inviters can't run a live relay subscription, poll instead:
4a audience process-claims --address $AUDIENCE_ADDRESS --aud-id-priv $AUD_ID_PRIV
This scans the audience's pending invites for matching kind:30522 events.
On match it triggers a rotation that adds the claim_pubkey as a member and
drops the matched pending entries. Idempotent — the pending entry is gone
after rotation, so re-polling is a no-op.
A reasonable cadence: every 5 minutes via cron, or every load of the audience UI.
7. The NIP-05 directory + fa extension
https://4a4.ai/.well-known/nostr.json serves the standard NIP-05 shape
(names, relays) plus the optional fa extension keyed by pubkey to
{audiences, context}.
The directory body is supplied via the NOSTR_DIRECTORY_JSON env var:
wrangler secret put NOSTR_DIRECTORY_JSON --config gateway/wrangler.toml
# paste a JSON object matching the §7.2 schema, then enter
?name=<name> filters to a single name's entries per NIP-05 conventions.
CI test (gateway/src/__tests__/well-known.test.ts) covers every §7.4 invariant:
- every
fakey appears innames - every
audiencesentry is a valid slug - every
fa.contextequalshttps://4a4.ai/ns/v0
8. The claim page
distribution/claim/index.html is the static Cloudflare Pages site for
claim.4a4.ai. Single-page, vanilla JS, no framework.
8.1 Deploy
# From the 4A repo root, with wrangler authenticated against the 4A account:
wrangler pages deploy distribution/claim --project-name claim-4a-ai
# Then in Cloudflare DNS, add a CNAME record:
# claim → claim-4a-ai.pages.dev
The page parses 4a://invite/<slug>/<epoch>?k=4ainv1… URLs (in HTTPS form),
runs OAuth through api.4a4.ai/auth/github/start?return_to=…, then on
callback POSTs /v0/audience/claim and shows the result. The copy reinforces
SPEC-v0.5 §7.3 — this is one host of convenience, not a privileged authority.
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
/audience/create 401 |
JWT expired | Re-run OAuth at /auth/github/start |
/audience/invite 401 with "aud_id_priv does not match audience_address" |
Wrong priv passed | Ensure aud_id_priv is the value /audience/create returned for this slug |
/audience/publish 403 with "publisher is not a current member" |
Caller's KMS-derived pub isn't on the audience's roster | Run /audience/grant first to add the caller as a member |
/audience/publish 404 with "audience declaration not found in relay cache" |
The DO doesn't have the declaration cached | Republish the declaration via /audience/rotate (or wait for the next sweep) |
| Inbox returns no items | Same-instance cache is the only delivery in v0.5 | The publisher must be the same gateway instance that owns the reader's relay-pool DO; cross-instance subscription is a t15 follow-up |
| Claim page hangs after OAuth | ?inviter and ?address query params not present in the return URL |
The inviter's UI must embed both in the invite link copy that reaches the invitee |
| Gift-wrap unwrap fails locally | Reader doesn't hold the matching kind:30521 for the rumor's (audience, epoch) |
Ask the inviter to re-issue the grant for your current epoch |
10. The §5 worked example (ready-to-run, needs operator to execute)
docs/examples/v0.5/ carries ten deterministically-generated JSON fixtures
matching v0.5-design.md §5.5's table. The fixtures were built by
scripts/build-v0.5-fixtures.mjs using deterministic seed pubkeys; bytes are
identical across runs.
To exercise the round-trip against a live 4a4.ai relay:
# 1. Build & deploy the gateway with the v0.5 changes.
npm install
npm run deploy # wrangler deploy gateway/wrangler.toml
# 2. Sign in as Evan, capture $JWT_EVAN.
open https://api.4a4.ai/auth/github/start
# 3. Create the audience.
4a audience create --slug team-design --name team-design \
--description "Design notes shared with Allison."
# stash audience_address, aud_id_priv, aud_epoch_priv
# 4. Invite Allison.
4a audience invite --address $AUDIENCE --aud-id-priv $AUD_ID_PRIV
# email the https_url to allison@enginable.com
# 5. Allison claims via claim.4a4.ai → POST /v0/audience/claim happens in the
# page; the claim event lands on the relays.
# 6. Process the claim → rotate to epoch 2.
4a audience process-claims --address $AUDIENCE --aud-id-priv $AUD_ID_PRIV
# stash the new aud_epoch_priv from the rotation response.
# 7. Publish an Observation.
4a audience publish --address $AUDIENCE --aud-epoch-pub $AUD_EPOCH_PUB \
--kind 30510 --d "team-design-css-reset" --alt "encrypted Observation" \
--payload '{"@context":"https://4a4.ai/ns/v0","@type":"Observation",
"observationAbout":{"@id":"https://example.org/css-resets"},
"measuredProperty":"css-trick",
"value":"Saw a slick CSS reset…"}'
# 8. Read it back as Allison.
4a audience inbox --slug team-design --limit 10
After the run, capture the ten on-the-wire events (declaration v1, founding
grant, declaration v1', claim, declaration v2, two epoch-2 grants,
encrypted-variant rumor, two gift-wraps) and diff their JSON shapes against
docs/examples/v0.5/. Differences in created_at, id, sig, and NIP-44
nonces are expected; the tag/content shape MUST match.
11. Security model
- Capability-based access. Anyone holding
aud_epoch_n_privcan decrypt audience-addressed events from epochn. There is no relay-level access control; the relay only sees opaque ciphertexts. - Lose-OAuth-lose-access. Custodial readers' identity privs are
deterministically re-derived from
(provider, oauth_id)via KMS; lose the OAuth account, lose the ability to re-derive. v0.5 ships no rotation. NIP-46 (bunker) is the escape hatch — bring your own signer. - Forward secrecy. None at the protocol layer in v0.5. Removed members forget the new epoch's priv but still hold the old one; old ciphertexts remain decryptable to anyone who held the old key. NIP-104 / MLS migration (v1) addresses this.
- Metadata privacy. NIP-17 gift-wrapping conceals the audience-membership
graph from relay operators. The §4 MUST is load-bearing; bare
kind:30510-30514events MUST NOT be published.
12. Companion docs
SPEC-v0.5.md— normative event shapesv0.5-design.md— design rationale + locked decisionsPLAN-v0.5.md— task graph + open questions with defaultsdocs/examples/v0.5/README.md— fixture set index