flotilla/.agents/skills/welshman-app/SKILL.md
2026-07-29 15:52:13 -07:00

9.9 KiB

name description
welshman-app Use this skill when working with @welshman/app: the App instance and its plugins, sessions and login, publishing via Commands and Thunks, app policies, WoT, feeds, sync, or relay selection at the app layer.

welshman/app — The App Instance and its Plugins

@welshman/app composes net, store, domain, signer, and feeds into an application framework built around a single App object.

Installation

npm i @welshman/app

The App

import {App, createApp, User} from "@welshman/app"

const app = createApp({
  user: await User.fromSigner(signer),   // omit for a signed-out app
  config: {
    dufflepudUrl: "https://dufflepud.example.com",
    getDefaultRelays: () => ["wss://relay.example.com"],
    getIndexerRelays: () => ["wss://indexer.example.com"],
    getSearchRelays: () => ["wss://search.example.com"],
  },
  getAdapter,                            // optional: custom net adapters (tests, mocks)
  policies,                              // optional: overrides the defaults
})

An App owns everything scoped to one identity:

Property What it is
app.user the signed-in User, or undefined
app.config the AppConfig above
app.repository this identity's event store
app.tracker which relays each event was seen on
app.pool socket pool
app.wrapManager NIP-59 gift wrap bookkeeping
app.netContext {pool, repository, getAdapter} for the net layer
app.use(Plugin) resolve a per-app plugin singleton
app.cleanup() run policy teardown and clear pool/tracker/repository

createApp is new App plus defaultAppPolicies. Use new App({policies: [...]}) for a bare app.

An app is scoped to one identity. To log in, build a new app and cleanup() the old one — never attach a user to an existing app. That's what keeps one account's data out of another's repository.

Plugins

app.use(Ctor) constructs the plugin on first use and memoizes it per app, so calling it inline is cheap and idiomatic:

app.use(Profiles).load(pubkey)
app.use(RelayLists).writeUrls(pubkey).get()

Plugin base classes

Base Shape
MapPlugin<T> a plain keyed map of non-event data (relay stats, NIP-11 info)
LoadableMapPlugin<T> a MapPlugin that knows how to fetch(key) from the network
DerivedPlugin<T> a keyed collection derived from the repository — the repository is the source of truth, never a duplicated map
RelayScopedDerivedPlugin<T> keyed by getKey(item, url) per relay, for data that only means something relative to a relay
RelaySignedDerivedPlugin<T> the same, but only accepts events authored by the relay's NIP-11 self pubkey (NIP-29 room state, relay membership/roles)

Derived plugins expose:

  • index — Projection<ItemsByKey<T>>
  • all — Projection<T[]>
  • one(key) — a store for a single key, loading it on first subscribe
  • get(key) — synchronous snapshot
  • load(key) / forceLoad(key) — network fetch (cached / uncached)
  • project(key, read) — a Projection derived from one key

A Projection<T> is {get(): T, $: Readable<T>} — bind .$ in markup, call .get() in callbacks and hot paths. Build new ones with projection(store) or projectFrom(source, read).

Available plugins

Core: Network, Router, Domain, Thunks, Sync, Logger, Plaintext

Relays: Relays (NIP-11), RelayStats, RelayManagement (NIP-86), RelayLists, BlockedRelayLists, SearchRelayLists, MessagingRelayLists, BlossomServerLists

People: Profiles, FollowLists, MuteLists, Handles, Zappers, Wot, Topics

Content: Reactions, Deletes, Pins, Pinboards, Feeds, FeedLists, Wraps

NIP-29 / membership: Rooms, RoomLists, RoomPinLists, RelayMemberLists, RelayRoles

Sessions and login

A Session is {method, ...data}, serializable so you can persist it. Handlers convert one into a signer: nip01, nip07, nip46, nip55, pomade, plus registerSessionHandler for your own.

import {User, createApp, nip07, toSession} from "@welshman/app"

const session = toSession(nip07, {pubkey})
const user = await User.fromSession(session)   // undefined if the handler can't build a signer

const app = createApp({user})

User wraps a signer and pubkey:

  • User.fromSigner(signer) / User.fromSession(session)
  • User.require(app) — the signed-in user or throws; use on paths that require login
  • user.sign(event), user.wrapSigner(fn)

Persist the Session, not the User — rebuild the user on startup and construct the app with it.

Publishing

Two layers, and you usually want the first.

Commands

A Command owns a rendered event plus the relays routing resolved for it:

import {Domain, publish} from "@welshman/app"
import {Note} from "@welshman/domain"

const writer = app.use(Domain).writer(Note).setContent("hello")
const command = await app.use(Domain).command(writer)

command.publish()                 // to the resolved relays
command.publishToRelays(urls)     // to specific relays
command.publishAsRelay(url)       // signed by the relay itself (NIP-86)

Plugin mutators already return a Command, so .then(publish) is the common shape:

await app.use(FollowLists).follow(["p", pubkey]).then(publish)
await app.use(RelayLists).addWriteUrl(url).then(publish)

Free-function forms exist for pipelines: publish, publishToRelays(urls), publishAsRelay(url), signAsRelay(url).

Thunks

app.use(Thunks).publish({event, relays, delay}) publishes optimistically: the event lands in the local repository immediately, so the UI updates before the network settles. The returned Thunk is a store you can render:

const thunk = app.use(Thunks).publish({event, relays})

thunk.getUrlsWithStatus(PublishStatus.Success)
thunk.getFailedUrls()
thunk.isComplete()
await thunk.waitForError()        // "" when everything succeeded
await thunk.waitForCompletion()

app.use(Thunks).history is a writable of every thunk this app has published — useful for a "sending" indicator or deciding which relays the user has actually written to.

Requests

const network = app.use(Network)

network.load({relays, filters})            // batched, deduped, shared loader
network.request({relays, filters, onEvent})
network.publish({event, relays})
network.loadUsingOutbox(pubkey, filter)     // newest matching event from the author's write relays
network.loadAllUsingOutbox(pubkey, filter)  // every matching event

Prefer a plugin's one(key) / load(key) when one exists — they handle outbox routing and caching for you. The bare load/request/publish from @welshman/net need an explicit context; Network supplies app.netContext.

Relay selection

Routing is the RelaySelection DSL from @welshman/util, resolved by app.use(Router):

import {outbox, inbox, seen, userOutbox, indexers, relay, relays} from "@welshman/util"

const scenario = await app.use(Router).resolve([userOutbox(), outbox(pubkey)])
const urls = scenario.getUrls()

// single best relay for a route
const hint = await app.use(Router).resolver.relay([outbox(event.pubkey)])

Selections are weighted (outbox(pubkey, 2)), and resolution is async — it may need to load the target's relay list first.

App policies

An AppPolicy is (app) => Unsubscriber, applied once at construction and torn down by cleanup(). Policies own everything that subscribes or wires components together, keeping the data classes free of side effects.

Built-ins: appPolicyIngest, appPolicyRelayStats, appPolicyWraps, appPolicyCacheDecrypt, appPolicyLogSignerMethods, plus auth: appPolicyAuthNever, appPolicyAuthAlways, appPolicyAuthUnlessBlocked, and makeAppPolicyAuth(shouldAuth) for a custom predicate.

const app = createApp({
  user,
  policies: [...defaultAppPolicies, appPolicyAuthUnlessBlocked, myPolicy],
})

const myPolicy: AppPolicy = app => {
  const unsubscribe = on(app.repository, "update", handleUpdate)

  return unsubscribe
}

Ordering gotcha: policies run in the App constructor. If a policy module imports something that transitively imports your app module, construct the app lazily (on first access) so every policy has registered by the time it's built.

Web of trust

const wot = app.use(Wot)

wot.follows(pubkey).get()
wot.followers(pubkey).get()
wot.network(pubkey).$              // follows-of-follows
wot.followsWhoFollow(pubkey, target).$
wot.wotScore(pubkey, target).$

Feeds and sync

app.use(Feeds).makeFeedController({feed, onEvent, ...})
app.use(Feeds).getPubkeysForScope(scope)
app.use(Feeds).forAuthor(pubkey).$

app.use(Sync).pull({relays, filters})   // negentropy: fetch what we're missing
app.use(Sync).push({relays, filters})   // publish what the relay is missing

Using welshman stores outside Svelte

Projections and plugin stores implement the Svelte store contract — subscribe(cb) → unsubscribe, firing synchronously with the current value — so they adapt to any reactive framework with a small hook. Only the svelte/store types are needed, not the runtime.

// React
const useStore = <T>(store: Readable<T>): T => {
  const [value, setValue] = useState<T>(() => get(store))

  useEffect(() => store.subscribe(setValue), [store])

  return value
}

For a Projection, subscribe to .$ and read .get() for a synchronous snapshot.

  • welshman-domain — the readers/writers every plugin decodes events with
  • welshman-net — sockets, adapters, request/publish lifecycle, auth
  • welshman-store — the repository and the derive helpers plugins are built on
  • welshman-signer — signer implementations behind User
  • welshman-util — kinds, filters, tag specs, and the RelaySelection DSL