feat(amethyst): two more read-only Gemini verbs + signer-prompt plan

Read-only surface for the Gemini bridge now covers profiles, notes, and
the active account's follow set:

  * searchNotes(query, limit) — NIP-50 search over kind:1 short text
    notes via SearchActions.searchNotesFilter + INostrClient.fetchAll.
    Returns NoteHit list (eventId, npub, content, createdAt). alpha09
    of androidx.appfunctions doesn't support List<Int> parameters, so
    no caller-configurable kinds — kind:1 only for now.

  * getFollowing(limit) — reads account.kind3FollowList.userList.value
    (already resolved through LocalCache) and projects to FollowedUser
    with display-name / nip05 / picture from cached kind:0. Reports
    totalFollowing so the caller knows when limit truncated the list.

Both verbs follow the searchProfiles pattern: snapshot active account +
client at entry, never re-query sessionManager during the dispatch.

Plus amethyst/plans/2026-05-25-appfunctions-signer-prompts.md —
design plan for write verbs. Three signers (Internal / Remote /
External), three different latency + interaction models. Concrete
proposal: Internal first via postNote pilot, Remote as a follow-up,
External via PendingIntent (Option A) or NotSupportedException
(Option B — recommended for v1) depending on what bundle keys the
system shell respects. Open questions enumerated so the experiment
day is bounded.
This commit is contained in:
Claude
2026-05-25 00:18:31 +00:00
parent 31cfb53b25
commit 95ca12231c
2 changed files with 388 additions and 0 deletions
@@ -0,0 +1,239 @@
# AppFunctions signer prompts — design
**Date:** 2026-05-25
**Status:** Draft — no code yet
How write verbs invoked from background Gemini context (via
`androidx.appfunctions` 1.0.0-alpha09 → `PlatformAppFunctionService`)
acquire a signature from each of Amethyst's three signer types. This
is the gating concern that has us only exposing read-only verbs so far
(`searchProfiles`, `searchNotes`, `getFollowing`).
## The three signer types and what each needs
| Signer | Where the private key lives | Sign call latency | Needs user interaction? |
|---|---|---|---|
| **`NostrSignerInternal`** | In-process keypair, loaded at login | Synchronous, microseconds | No |
| **`NostrSignerRemote`** (NIP-46 bunker) | Remote process — a wallet app, browser tab, separate device | Network round-trip via relays, seconds | Yes — the bunker app pops a confirmation on the user's other device |
| **`NostrSignerExternal`** (NIP-55, e.g. Amber) | Another Android app on the same device | Bound-service IPC + activity bounce | Yes — Amber shows an activity in the foreground asking the user to approve |
Each signer surfaces the same `suspend fun sign(...)` API. The difference is
**what happens to the foreground UI** while the sign is in flight.
## What App Functions gives us to work with
From the alpha09 artifact (`androidx.appfunctions:appfunctions-service`):
- **Suspending dispatch.** `executeFunction` is a suspend function — a slow
signer (NIP-46 round-trip) doesn't block the system shell.
- **Typed exceptions.** `AppFunctionPermissionRequiredException`,
`AppFunctionDeniedException`, `AppFunctionCancelledException`,
`AppFunctionAppException`. The non-default constructors take a `Bundle`
the system shell can interpret known keys (e.g. a `PendingIntent` to launch
an in-app confirmation). Concrete bundle contract is undocumented in
alpha09; needs a sample-app check or an experiment.
- **`PendingIntent`** is listed as a supported parameter type, which strongly
implies a returned `PendingIntent` can prompt the system to launch the
app's UI for follow-up.
- **No streaming.** Functions return one value or throw. There's no native
"in progress" / "user is approving" signal back to Gemini.
## Per-signer approach
### NostrSignerInternal — just works
Verb runs end-to-end inside the dispatch coroutine. `signer.sign(...)` is
synchronous. Publish via `client.publish(...)`. Return success.
**Verbs this covers immediately:** post, follow/unfollow, search-relay
list updates, kind:10002 changes — anything where the signed event is
sent and forgotten.
**Edge case — background `app.client`.** When the app process is
foreground-bound but the user is in Gemini, the client should be
connected. When the user has killed Amethyst recently, the service
process might be cold-started and the client not yet connected to any
relay. The verb needs to either:
- Wait for `client.connect()` (~hundreds of ms once the WebSocket is
established) — acceptable inside the 5-10s window.
- Use `INostrClient.publish(...)` which queues the publish for when the
connection comes up. Quartz needs to confirm this is the actual
behavior; might require `withTimeout` around the publish.
### NostrSignerRemote — the cleanest async case
The bunker sends a NIP-46 request to a relay, the bunker app sees it on
the user's other device, the user approves, the signed event comes back.
`signer.sign(...)` suspends until the response arrives or its internal
timeout fires (default 30s).
**Approach:** call `signer.sign(...)` from within the verb, with a
`withTimeout` budget aligned to App Functions UX expectations (Gemini
typically waits ~30s before showing the user "no response"). On
timeout, throw `AppFunctionCancelledException`. On success, publish and
return.
**Open question — concurrent foreground signing.** If the user is also
trying to send a post from the foreground UI at the same moment, the
bunker app gets two simultaneous requests. NIP-46 handles this — each
request has a unique id — but Amber-like bunker apps may queue both
prompts confusingly. Worth a manual test.
### NostrSignerExternal — the hard case
NIP-55 bounces to a separate Android app's activity. From a background
`PlatformAppFunctionService`, we can't directly `startActivity(...)`
there's no foreground intent stack to attach to.
**Two viable approaches:**
#### Option A — throw a typed exception with a PendingIntent
```kotlin
@AppFunction
suspend fun postNote(ctx: AppFunctionContext, text: String): PostResult {
val account = activeAccount() ?: throw AppFunctionDeniedException("not signed in")
if (account.signer is NostrSignerExternal) {
// Build a PendingIntent that opens Amethyst at a "approve this
// post" screen, with the draft text passed through extras.
val approvalIntent = buildApprovePostPendingIntent(account, text)
throw AppFunctionPermissionRequiredException(
message = "Amethyst needs to launch the external signer to approve this post.",
extras = bundleOf("pending_intent" to approvalIntent),
)
}
// … happy path for the in-process signer
}
```
The system shell renders "Open Amethyst to continue", user taps,
Amethyst opens, user approves through Amber's activity, post lands. The
Gemini conversation doesn't see the final result — the user has to come
back to Gemini and re-confirm.
**UX gap.** No way to communicate the eventual outcome back to Gemini's
chat. Acceptable for v1.
#### Option B — refuse write verbs when the signer is NIP-55
Throw `AppFunctionNotSupportedException` immediately. User configures a
different signer (local or NIP-46) to enable Gemini-driven writes.
Simpler, cleaner, but limits the audience — many Amethyst users on
Amber would lose the feature.
**Recommendation:** start with Option B, ship Internal + Remote support,
then add Option A behind a feature flag in a follow-up. Option B
unblocks the feature for ~70% of users today; Option A is more work
and has the unresolved "result doesn't get back to Gemini" wrinkle.
## Per-write-verb concerns
### postNote(text)
- Internal: sign → publish to outbox. Done.
- Remote: sign (suspends) → publish. Done.
- External: throw NotSupported, or PendingIntent dance.
- Side concern: should this go into the user's drafts vs immediately
publish? Gemini-issued posts feel like they should publish (the
user asked for it), but a "review before post" screen via PendingIntent
is a nice safety net even for the local-signer path.
### follow(npub) / unfollow(npub)
- Same signer paths as postNote, simpler payload.
- Reads the current kind:3, modifies, signs, publishes — `FollowActions`
is ready.
- **No** "preview" step needed — follow/unfollow is reversible.
### sendDm(recipient, text)
- Same signer paths.
- `DmActions.buildTextDm` is ready, plus `resolveDmRelays`.
- **Concern**: strict mode (default) refuses to send when recipient has
no kind:10050. Should Gemini's `sendDm` default to strict or
permissive? Argument for strict: it's NIP-17 spec behavior. Argument
for permissive: Gemini users won't know what kind:10050 is and will
see confusing failures. **Lean: permissive by default**, surface the
source in the result.
### zapUser(npub, sats, comment?)
- Same signer paths for the kind:9734 zap request.
- But there's a *second* signing-like step: an LN payment via NWC
(if configured). NWC has its own permission model and can also fail.
- For v1: build the zap request, fetch the BOLT11 invoice, return the
invoice in the result. User pays via their wallet. Skip NWC
auto-payment.
### zapEvent(eventId, sats, comment?)
- Same as zapUser but uses `ZapActions.buildEventZapRequestsForSplits`
so multi-party notes route correctly.
- May return multiple invoices (one per split recipient).
## Account selection
All verbs read `Amethyst.instance.sessionManager.loggedInAccount()` once
at entry. **Multi-account question**: should Gemini be able to specify
*which* account to act as? Two answers:
- v1: no — always act as the currently-active account. Matches what the
user sees in the foreground UI. Simpler.
- Later: add an optional `accountNpub: String?` parameter to each write
verb. Defaults to the active account.
Start with v1.
## Permissions surfaced to Gemini
The App Functions schema XML (auto-generated by KSP) lists each verb
plus its parameters. Gemini's tool picker shows these to the user. We
should add a `description` (via `isDescribedByKDoc = true`, which we
already do) that makes write verbs sound consequential — "Publishes a
note to your Nostr followers", not "Calls postNote".
## Open questions for an experiment day
1. What concrete `Bundle` keys does the system shell respect on
`AppFunctionPermissionRequiredException`? Run a tiny test app, throw
the exception with various bundle contents, observe what Gemini
surfaces.
2. Can the user approve a Gemini-issued write from within the Gemini
chat (inline confirmation) or only by opening Amethyst? Affects
Option A's UX.
3. Does `INostrClient.publish(...)` actually queue when the relay
pool is disconnected, or does it return immediately with no
delivery? Determines whether the verb needs an explicit
"wait for at least one OK" gate.
4. NWC and Gemini: if the user has a NWC wallet configured, should
`zapUser` auto-pay? Adds another consent layer.
## Minimum viable first write verb
Pick **`postNote(text)`** as the pilot.
Why:
- Simplest: one signed event, one publish, one ack.
- Read-back is straightforward: return the event id + the relays it
landed on. Gemini can compose "Posted! Here's the link: nostr:nevent…".
- Failure modes are well-bounded (signer error, no outbox relays, all
relays rejected).
- No multi-party complexity (zap splits, DM strict mode).
Scope of the pilot:
- `NostrSignerInternal` only (Option B for NIP-55, Remote in a
follow-up). Document the cutoff in kdoc.
- Returns `PostNoteResult(eventId, publishedTo, rejectedBy)` — an
`@AppFunctionSerializable`.
- Builds on the existing `commons/.../quartz/.../TextNoteEvent.build`
and `client.publish` — no new actions needed.
- ~50 lines of new code in `AmethystAppFunctions.kt`, plus the result
class.
Once shipped, follow-ups in order:
1. `follow(npub)` / `unfollow(npub)` — same signer caveat.
2. NIP-46 (Remote) signer support — change the gate from "signer is
internal" to "signer can sign in-process".
3. `sendDm(recipient, text)` — first write verb that uses encryption.
4. `zapUser` / `zapEvent` — non-trivial because of LN flow.
5. NIP-55 support via PendingIntent (Option A) once the system-shell
contract is understood.
No write-verb code lands until question (1) above is answered —
otherwise the NIP-55 path is undefined and we ship something that
"sort of works" for half our users.
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.actions.SearchActions
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
/**
@@ -126,6 +127,100 @@ class AmethystAppFunctions {
)
}
/**
* Searches Nostr notes for [query] via NIP-50 full-text search across the
* active account's configured search relays (kind:10007), falling back to
* Amethyst's curated default search-relay set when none is configured.
*
* Defaults to short text notes (kind:1) only. Currently no way to widen
* to long-form or other kinds — add a parameter when the need is real;
* App Functions doesn't support `List<Int>` parameters in alpha09.
*
* @param query free-form search text.
* @param limit max number of notes to return — capped to 50.
*/
@AppFunction(isDescribedByKDoc = true)
suspend fun searchNotes(
appFunctionContext: AppFunctionContext,
query: String,
limit: Int = 20,
): SearchNotesResult {
val cappedLimit = limit.coerceIn(1, 50)
val filter = SearchActions.searchNotesFilter(query, limit = cappedLimit) ?: return SearchNotesResult.empty()
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty()
val client = Amethyst.instance.client
val relays = account.searchRelayList.flow.value
if (relays.isEmpty()) return SearchNotesResult.empty()
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
events
.mapNotNull { it as? TextNoteEvent }
// Sorted newest-first by fetchAll already, but the cast may
// have dropped non-kind:1 events from a relay that ignored
// our kinds filter.
.take(cappedLimit)
.map { ev ->
NoteHit(
eventId = ev.id,
npub = NPub.create(ev.pubKey),
pubkeyHex = ev.pubKey,
createdAt = ev.createdAt,
content = ev.content,
)
}
return SearchNotesResult(matches = hits)
}
/**
* Lists the active account's current follow set — the people the signed-in
* user follows per their latest NIP-02 kind:3 contact list.
*
* Returned entries include best-effort display names sourced from each
* user's cached kind:0; users with no cached metadata appear with
* [FollowedUser.displayName] null. The order matches the on-disk follow
* list (which is the order the user followed them in).
*
* @param limit cap on entries returned — capped to 500. Set to 0 for the
* full list when there's no specific bound.
*/
@AppFunction(isDescribedByKDoc = true)
suspend fun getFollowing(
appFunctionContext: AppFunctionContext,
limit: Int = 100,
): FollowingResult {
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return FollowingResult.empty()
// userList resolves authors through LocalCache so display names /
// pictures / nip05 are filled in for anyone whose kind:0 we've seen.
val users = account.kind3FollowList.userList.value
val effectiveLimit = if (limit <= 0) users.size else limit.coerceIn(1, 500)
val out =
users
.take(effectiveLimit)
.map { user ->
val meta = user.metadataOrNull()
FollowedUser(
npub = NPub.create(user.pubkeyHex),
pubkeyHex = user.pubkeyHex,
displayName = meta?.bestName(),
nip05 = meta?.nip05(),
picture = meta?.profilePicture(),
)
}
return FollowingResult(totalFollowing = users.size, returned = out)
}
companion object {
/**
* 6-second fetch window. App Functions invocations are user-initiated
@@ -167,3 +262,57 @@ class SearchProfilesResult(
fun empty() = SearchProfilesResult(matches = emptyList())
}
}
/** Single match in [SearchNotesResult]. */
@AppFunctionSerializable(isDescribedByKDoc = true)
class NoteHit(
/** Hex event id of the note. */
val eventId: String,
/** Bech32 npub of the note's author. */
val npub: String,
/** Hex pubkey of the note's author. */
val pubkeyHex: String,
/** Unix-seconds timestamp the note was created at. */
val createdAt: Long,
/** Raw content of the note (plain text, may contain Nostr URIs / hashtags). */
val content: String,
)
@AppFunctionSerializable(isDescribedByKDoc = true)
class SearchNotesResult(
/** Matched notes, sorted newest-first by created_at. */
val matches: List<NoteHit>,
) {
companion object {
fun empty() = SearchNotesResult(matches = emptyList())
}
}
/** Single entry in [FollowingResult]. Metadata fields may be null when the
* user's kind:0 hasn't been cached locally. */
@AppFunctionSerializable(isDescribedByKDoc = true)
class FollowedUser(
/** Bech32 npub of the followed user. */
val npub: String,
/** Hex pubkey of the followed user. */
val pubkeyHex: String,
/** Best-effort display name (display_name then name). */
val displayName: String?,
/** NIP-05 verified handle, e.g. `alice@example.com`. */
val nip05: String?,
/** Avatar image URL. */
val picture: String?,
)
@AppFunctionSerializable(isDescribedByKDoc = true)
class FollowingResult(
/** Total number of follows in the active account's kind:3 — may exceed
* [returned] when the caller passed a limit. */
val totalFollowing: Int,
/** Subset of follows returned to the caller, in original on-disk order. */
val returned: List<FollowedUser>,
) {
companion object {
fun empty() = FollowingResult(totalFollowing = 0, returned = emptyList())
}
}