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.
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
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>", "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"
}
]
}
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