Browse API 1 documentation
GroveAPI 1preview

JavaScript bridge

Events and methods exposed through window.grove.

Mango 3.1+View Markdown

This guide explains how the bridge fits together. For complete per-function contracts, use the API Reference and Event reference.

Mango injects a frozen window.grove facade before extension scripts run. grove is also available as a global variable. The native host remains the security authority: each event and method is checked against the instance’s scope and granted capabilities.

Most commands are fire-and-forget. Secret operations and upload file reads return promises. Rejected, malformed, or ungranted commands are dropped and logged in the extension console.

Runtime information

js
grove.info
// {
//   id: 'com.example.grove.extension',
//   api: 1,
//   networkID: 'opaque-network-id',
//   scope: { kind: 'channel', target: '#grove' }
// }

The object is host-owned context, not a permission token.

Application-scoped Roots receive an empty networkID and { kind: 'application', target: null }. Their IRC event and command surface is disabled; see Surfaces and scopes.

Events

Share grove://run/<extension-id>/<argument>/... to open an installed Sprig with data, for example:

text
grove://run/com.grove.poll/sdf8cvszxc80sf90?mode=vote
js
grove.on('launch', ({ args, parameters }) => {
  const pollID = args[0]
  // Validate the ID, load the poll through the granted network API, show its UI.
  // Opening a link must not itself cast a vote or perform a destructive action.
  showPoll(pollID)
})

The event payload is:

ts
interface GroveLaunchEvent {
  url: string
  extensionID: string
  args: string[]
  parameters: { name: string; value: string | null }[]
}

Path arguments are percent-decoded once, independently: build each with encodeURIComponent. Query parameters preserve order, repeated names, and valueless flags (null). An absent query yields an empty array. The entire URL is limited to 4096 UTF-8 bytes; credentials, ports, fragments, empty path arguments, and decoded control characters are rejected. Parameters are untrusted data, not JavaScript or a package download location. Do not put credentials in shared links.

Mango opens a fresh instance using the manifest’s normal surface/scope and the selected conversation, respecting saved capability grants and requesting consent when needed. Composer utilities are application-scoped as usual; they do not gain IRC access from the link. No commands.register capability is required to receive launch. Existing instances displaced by the usual surface rules are replaced. Select a network first if none exists; cold-start links wait for that selection. While another permission prompt, installation, or the manager is active, Mango retains the newest link and its original conversation scope until that operation finishes and the manager closes.

The launch event is retained until a handler subscribes, including handlers registered after asynchronous initialization. It is delivered again after a WebContent recovery/reload, so use it to restore a view, not to automatically submit a vote. Ordinary launches from the manager or composer menu do not emit it. This is an additive API 1 feature; older Mango builds do not support launch links.

If the extension is absent, Mango offers to open the curated Extension Store. The user installs it through the normal flow and then opens the shared link again. The URL never authorizes downloading or installing a package, and it cannot grant host/network permissions.

For a poll application, an on-demand composer surface is a good fit: show creation UI on an ordinary launch, and voting/results UI on launch. Use grove.composer.complete(url) to insert a new poll link in Mango’s draft for the user to send. The extension and its backend own poll storage, voting authorization, and results; Mango provides only launching and sharing.

Register and remove listeners with grove.on and grove.off:

js
function receive(message) {
  console.log(message.from, message.text)
}

grove.on('message', receive)
grove.off('message', receive)

With the matching read capability, a message payload contains:

ts
interface GroveMessage {
  kind: 'channel' | 'dm'
  from: string
  own?: boolean
  account?: string
  host?: string
  text: string
  channel?: string
  id?: string
  receipt?: string
  receiptCorrelation?: 'labeledResponse' | 'bestEffort' | 'transportAccepted'
}

Prefer account as a stable sender identity when it is present. host is a fallback. Nicknames can change and should not be treated as durable authentication.

grove.on('messageEcho', callback) reports output from the current IRC identity. With IRCv3 echo-message, it reports server self-echoes, including output from other clients attached to the same bouncer; an event without your receipt is not acknowledgement of your send. Without echo-message (or for local bouncer-service sends that bypass echo tracking), a receipt event reports native transport acceptance after the complete send. Outgoing direct-message echoes appear only on this event. Outgoing channel messages continue to appear on message with own: true for compatibility and are also copied to messageEcho. Keeping the acknowledgement event separate prevents an autonomous Root from treating its own private output as new input.

Supply a fresh caller-generated UUID as receipt to grove.send or grove.reply. Only the live originating Sprig instance receives that receipt; normal scope and read-capability checks still apply. Mango keeps the receipt native-only. When echo-message and labeled-response are negotiated, Mango generates a separate IRC label and stores the mapping from that label to the receipt and originating instance. The returned label, directly on the echo or inherited from a response batch, identifies the send even if the server rewrites its text. An unlabeled echo never consumes a pending labeled send.

Inspect receiptCorrelation alongside receipt:

Value Meaning
labeledResponse The server echo was correlated to this send using its IRC label; no text equality is required.
bestEffort The send had no label and Mango matched target and exact wire text, oldest first. Server rewriting can prevent a match; identical output from another attached client can be ambiguous. Reconnect-history reconciliation, if surfaced as a receipt, is also best-effort.
transportAccepted The native transport accepted the send. There is no server acknowledgement.

The field is omitted when there is no receipt. Older Mango versions omit it even on receipt events; treat an absent value as unknown, not as strong correlation. Receipt correlation does not prove delivery to recipients or that a human read the message. IRCv3 permits servers to echo silently filtered messages.

For a send split into multiple logical messages, the receipt is attached to the last logical message; a labeled echo confirms that message, not an atomic acknowledgement of all fragments. No-echo transport acceptance waits for the complete send. Receipts are not server-side idempotency keys and pending mappings are not durable across app or Sprig restarts. A missing receipt is an unknown outcome, not proof of failure. Persist pending work before sending and use bounded, duplicate-safe retries appropriate to your application; do not promise exactly-once delivery.

A server-scoped extension with channel.read receives live message events for every confirmed joined channel. It can also track the host-owned roster:

js
grove.on('channels', ({ channels, caseMapping }) => {
  renderChannelTabs(channels)
  configureIRCIdentifierFolding(caseMapping)
})

The latest channels payload is retained by the facade, so a listener registered after document startup is called immediately. The array contains confirmed joins only; pending or parted channels are removed. caseMapping is ascii, rfc1459, or strict-rfc1459, matching the IRC server’s negotiated identifier folding. Use it when comparing channel names or keying per-channel state; JavaScript toLowerCase() is not RFC 1459 folding. This is a roster of available channel contexts, not channel.members and not evidence of send authority.

With channel.members, channel.metadata, or both, a server-scoped Root also receives a retained authoritative snapshot. Registering late immediately returns the latest value:

js
grove.on('channelSnapshots', ({ channels }) => {
  for (const channel of channels) {
    console.log(channel.channel, channel.channelKey, channel.topic, channel.modes)
    for (const member of channel.members || []) {
      console.log(member.nickname, member.memberKey, member.modes, member.prefixes, member.away)
    }
  }
})

modes contains IRC mode characters without +. Member snapshots retain every negotiated multi-prefix membership mode, so an op who is also voiced carries modes: ['o', 'v'] and prefixes: '@+'. Channel metadata exposes only safe boolean modes; Mango never exposes channel keys or other sensitive argument-bearing mode values. Omitting a capability omits its corresponding fields rather than granting them through another UI capability.

A registered command delivers command, whitespace-split args, the unsplit argString, and optional target and canonical targetKey. A member action delivers the action identifier plus host-canonical network, channel, and member context. Lifecycle payloads use state: 'foreground' | 'background' | 'pulse'.

Messaging

js
grove.send('Hello')
grove.send('Hello privately', { to: 'alice' })
grove.send('HELP', { to: 'ChanServ', open: true })
grove.send('Hello from a network tab', { channel: '#grove' })
grove.notice('Maintenance starts soon')
grove.notice('One-way private notice', { to: 'alice' })
grove.reply(message, 'Reply in the correct channel or query')
grove.reply(message, 'Durable reply', { receipt: crypto.randomUUID() })
grove.typing('active')
grove.typing('done', { to: 'alice' })
grove.typing('active', { channel: '#grove' })
grove.away('Lunch')
grove.away()

Untargeted sends use the bound channel or query and require the corresponding scoped capability. grove.reply follows the supplied message by forwarding an explicit target. In the current implementation, channel replies therefore require a server-scoped instance with network.send; a conversation-bound channel instance must use grove.send(text) without reply metadata instead. Replying to a direct message requires query.send and an existing query. See grove.reply() for this implementation limitation.

grove.send(text, { receipt }) and grove.reply(message, text, { receipt }) accept an optional UUID for native delivery correlation. A malformed receipt rejects the complete operation. The receipt does not grant a capability or alter target resolution.

{ to: nick } requires the matching query capability and an existing query. For an explicit user-facing transition, grove.send(text, { to: nick, open: true }) additionally requires query.open; Mango creates and selects that private conversation before sending. { channel: name } for send, reply, and typing is available only to a server-scoped instance with network.send. Mango accepts a strict channel-shaped target only when that IRC session has a confirmed matching conversation buffer. { to } and { channel } are mutually exclusive, open is valid only with an explicit to, and leaving both targets out never gives server scope an implicit destination. API 1 does not grant server-wide NOTICE; network.send does not widen channel.notice. away changes the entire IRC connection and requires presence.set.

API 1 has no raw IRC command method.

Composer results and uploads

A composer utility can close with bounded text for the native draft:

js
grove.composer.complete('https://example.com/share/123')
grove.composer.close()

Search utilities can opt into native focus handoff after rendering their input:

js
grove.composer.requestFocus?.('query')

The argument is an element ID, not a selector. On iOS, an already-focused native composer can hand its keyboard to the field; a hidden keyboard stays hidden. The request cannot take focus after the user moved elsewhere. This additive API 1 method requires feature detection on older Mango 3.1 builds. See grove.composer.requestFocus() for the complete contract.

With the uploads.use capability, it can instead hand one generated or downloaded file to Mango’s configured uploader:

js
const response = await grove.network.request(previewURL, {
  headers: { Authorization: `Bearer ${token}`, Accept: 'image/*' }
})
if (!response.ok) throw new Error(`Preview failed with HTTP ${response.status}`)

grove.composer.upload(response.body, { filename: 'immich-photo.jpg' })

data must be a non-empty Uint8Array or ArrayBuffer no larger than 8 MiB, and filename is required. Mango closes the utility, materializes the bytes in private temporary storage, resolves the current IRC session’s selected native upload destination, and puts the returned URL in the draft. The extension receives neither upload credentials nor the URL. The user still reviews and sends the ordinary message; this method never sends IRC traffic. Invalid or ungranted calls are dropped and logged.

See giphy-composer v0.1.10 for URL completion and on-demand commands, and immich-composer v0.1.0 for both link completion and bounded binary upload.

OAuth authorization

For a complete integration guide covering provider configuration, direct and brokered flows, retries, and token lifecycle, start with OAuth integrations.

An extension with oauth.authorize can start an OAuth Authorization Code flow in the system authentication UI:

js
const authorizationURL = new URL('https://identity.example.com/oauth/authorize')
authorizationURL.searchParams.set('client_id', 'public-client-id')
authorizationURL.searchParams.set('scope', 'openid profile offline_access')

const authorization = await grove.oauth.authorize(authorizationURL.toString())
// {
//   code: 'one-time-authorization-code',
//   codeVerifier: 'host-generated-pkce-verifier',
//   redirectURI: 'mango://grove/oauth',
//   callbackURL: 'mango://grove/oauth?code=…&state=…'
// }

Mango requires the authorization URL to begin at an exact HTTPS origin in hostPermissions. It removes any extension-supplied response_type, redirect_uri, state, code_challenge, and code_challenge_method, then supplies response_type=code, its fixed callback, a cryptographically random state, and an S256 PKCE challenge. The promise resolves only after the callback path and state validate. Provider rejection, user cancellation, a malformed callback, or a second concurrent app-wide request rejects the promise.

The extension then exchanges the code at its declared token endpoint:

js
const body = new URLSearchParams({
  grant_type: 'authorization_code',
  client_id: 'public-client-id',
  code: authorization.code,
  redirect_uri: authorization.redirectURI,
  code_verifier: authorization.codeVerifier
})

const response = await fetch('https://identity.example.com/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body,
  credentials: 'omit',
  redirect: 'error'
})

Use a public OAuth client with PKCE; a client secret cannot remain secret inside an extension package. The provider must accept mango://grove/oauth as an exact redirect and permit the extension’s opaque grove-ext://<extension-id> origin to call its token endpoint, or the extension must use the separately granted native request bridge described below. Store access and refresh tokens with grove.secrets, never in Web storage or logs. grove.oauth.redirectURI exposes the fixed callback for providers that need it while constructing configuration.

Pass { prefersEphemeralSession: true } only when the user explicitly expects an isolated browser session. The default retains the provider’s normal system sign-in experience and shared cookies.

Brokered PKCE authorization

Some services, including Immich, need the callback, state, and PKCE challenge before their API can create the actual authorization URL. Prepare a host-owned transaction first:

js
const transaction = await grove.oauth.prepare()
// transactionID, redirectURI, state, codeChallenge, codeChallengeMethod: 'S256'

const start = await grove.network.request(`${server}/api/oauth/authorize`, {
  method: 'POST',
  headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
  body: JSON.stringify({
    redirectUri: transaction.redirectURI,
    state: transaction.state,
    codeChallenge: transaction.codeChallenge
  })
})
const { url } = start.json()
const authorization = await grove.oauth.authorize(url, { transactionID: transaction.transactionID })

Mango still owns the state, verifier, callback validation, and visible system session. JavaScript receives no verifier from prepare(). authorize(...) consumes the prepared transaction even if authorization fails, and a later prepare() replaces an unused transaction. Send authorization.callbackURL, authorization.codeVerifier, and the original state to the broker’s callback API according to that service’s contract. Do not reconstruct the callback URL from the code.

The brokered authorization URL is used unchanged: Mango does not verify that it contains the prepared callback, state, or S256 challenge. A trusted broker must preserve these values and ensure PKCE is enforced at exchange. See the broker trust boundary; host-generated material is not a guarantee that a broker uses it correctly.

Native HTTPS requests

network.request is for a user-selected or self-hosted HTTPS API that does not permit requests from the extension’s opaque browser origin. It is an explicit native CORS bypass and has its own consent item:

js
const response = await grove.network.request('https://photos.example.com/api/search/metadata', {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    Authorization: `Bearer ${token}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ type: 'IMAGE', order: 'desc', size: 60 })
})

if (!response.ok) throw new Error(`Request failed with HTTP ${response.status}`)
const payload = response.json()
// response.body is a Uint8Array; response.text() decodes UTF-8.

The destination must be HTTPS and match an accepted hostPermissions origin. Mango resolves and pins the address used for the connection, sends no browser or shared app cookies, never follows redirects automatically, and ignores response cookies. A redirect resolves as its 3xx response with the location header so the extension can validate it before making a separate request; that next request goes through host-permission, DNS, and private-address validation again. Supported methods are GET, POST, PUT, PATCH, and DELETE. Request headers are restricted to Accept, Authorization, Content-Type, If-Match, If-None-Match, X-API-Key, and the documented Immich token headers; cookie, proxy, origin, host, forwarding, and arbitrary custom headers cannot be supplied.

Mango permits four concurrent requests per extension host, a 512 KiB request body, an 8 MiB response body, 12 headers, and a 30-second request timeout. Returned headers are limited to cache metadata, content length/type, ETag, last-modified, and redirect location. The bridge throws for transport, destination, and size failures; redirects and ordinary HTTP error statuses resolve with ok === false so the extension can validate or present them safely.

Prefer ordinary fetch when the service deliberately supports CORS. Request network.request only when native transport is necessary, validate stored endpoints again before every use, keep bearer tokens in storage.secure, and never log request bodies or credentials.

Slash commands

A persistent Sprig or Root registers its complete command set at runtime:

js
grove.commands.register([{
  name: 'weather',
  summary: 'Look up or track weather',
  usage: '<subcommand> [city]',
  subcommands: [
    { name: 'get', summary: 'Show current conditions', usage: '<city>' },
    { name: 'track', summary: 'Track a city', usage: '<city>' }
  ]
}])

grove.on('command', ({ command, argString, target }) => {
  if (command === 'weather') showWeather(argString, target)
})

Names are lowercased and must contain no whitespace. Each call replaces the previous registration. Built-in Mango commands always win, and a name already owned by another extension is ignored. Subcommands are discovery metadata: /weather track Prague still arrives as command weather and argument string track Prague.

API 1 accepts at most 32 commands and 16 subcommands per command.

An exclusive composer utility instead declares the same metadata under contributions.commands in its manifest. Mango can then show and route the command without keeping the utility’s WebView alive. Invoking it starts the utility on demand and delivers the same command event shown above. The composer does not call grove.commands.register(...); see Composer-command contributions.

Native cards

Command output can appear above the composer without inserting message text:

js
grove.card({
  id: 'weather-prague',
  title: 'Prague',
  body: '22 °C · Partly cloudy',
  markdown: '| | |\n| --- | --- |\n| Temperature | `22 °C` |\n| Conditions | **Partly cloudy** |',
  actions: [
    { label: 'Refresh', command: 'weather', args: 'get Prague' }
  ],
  menus: [{
    label: 'Actions',
    items: [
      { label: 'Stop tracking', command: 'weather', args: 'remove Prague', style: 'destructive', closes: true }
    ]
  }]
})

grove.dismissCard('weather-prague')

Reusing a card id replaces it. Buttons and menu items may invoke only a command registered by the same extension. Mango adds Copy and Close. Cards are host-rendered and bounded to three per buffer, six buttons and three menus per card, and twenty items per menu.

body is always required plain text: Mango uses it for Copy, extensions written before rich cards continue to render exactly as before, and older Mango versions use it as the fallback. A card may additionally provide markdown. Mango renders Markdown natively rather than as HTML; inline emphasis, strong text, code, links, and simple two-column pipe tables are supported, while raw HTML is never rendered. An older Mango version ignores the additional field and still shows body.

An ordinary command card returns to the buffer where that slash command was invoked. A server-scoped Root may route asynchronous output to an existing private conversation by adding to: 'nickname'; Mango requires the separately granted query.open capability and validates one strict IRC nickname. This does not select the query:

js
grove.card({
  id: 'verification-alice',
  to: 'Alice',
  title: 'Verification requested',
  body: 'Alice asked you to complete verification.',
  actions: [{ label: 'Show status', command: 'verify', args: 'status Alice' }]
})

command-lab-root v0.1.0 is a compact commands-and-cards reference. weatherbot v0.2.1 combines cards with external data, storage, channel events, and status badges.

Status badges

js
grove.badges.set([{
  id: 'prague',
  icon: 'cloud.sun',
  text: '22°C',
  subtitle: 'Prague, Czechia',
  color: 'neutral'
}])

Each call replaces the complete badge list; pass [] to clear it. At least icon or text is required. Colors are neutral, accent, success, warning, or danger. Mango supplies attribution and removes badges when the instance stops. API 1 accepts four badges per extension.

Member-menu actions

js
grove.memberActions.register([{
  id: 'toggle-relay-bot',
  label: 'Mark as Relay Bot',
  selectedLabel: 'Unmark as Relay Bot',
  selectedMemberKeys: ['ergobridge'],
  selectedIcon: 'arrow.triangle.branch'
}])

The call replaces this extension’s native member actions. Mango owns the menu, canonicalizes IRC keys, and delivers only an explicit user selection through the memberAction event. API 1 accepts eight actions.

Isolated web panes

A server-scoped headless Root can turn an explicit native action into a packaged service pane without giving the pane the Root’s bridge:

js
grove.memberActions.register([
  { id: 'show-profile', label: 'Show profile' }
])

grove.on('memberAction', ({ action, member, channel }) => {
  if (action !== 'show-profile') return
  grove.webPane.open(`profile.html?user=${encodeURIComponent(member)}`, {
    channel,
    id: 'member-profile'
  })
})

grove.webPane.open(path, { channel, id }) requires ui.web-pane, a package-local HTML path, and a confirmed joined channel on the same IRC session. It is unavailable to visible Sprigs, composer utilities, conversation-scoped Roots, and application Roots. The optional id defaults to main; it is bounded and identifies one pane owned by that Root. Reusing the id replaces the page and selects the same logical pane rather than accumulating duplicates.

The packaged page receives no window.grove, IRC messages, member data, secrets, or native request bridge. Network data requests use the owner’s approved origins and browser CORS. Mango confines execution and navigation to the installed package. A deliberate external link uses the user’s ordinary Mango link destination; remote/scripted navigation is denied. Stopping, replacing, or uninstalling the Root also closes its panes. See Security model.

The repository’s Examples/grove-omg-lol-profiles-root package is the complete two-action reference: it fetches public address JSON or offers an external website link from the native member menu and reuses one packaged pane for both.

Channel-menu actions

js
grove.channelActions.register([{
  id: 'channel-info',
  label: 'ChanServ INFO'
}])

grove.on('channelAction', ({ action, channel, channelKey }) => {
  if (action === 'channel-info') grove.send(`INFO ${channel}`, { to: 'ChanServ', open: true })
})

The call replaces this extension’s native joined-channel actions. Mango owns the menu and supplies the displayed and canonical channel identities only after an explicit user click. API 1 accepts eight actions.

Member and channel badges

Context badges are native, bounded, semantic, and visibly attributed to their extension. Use canonical keys from channelSnapshots:

js
grove.memberBadges.set([{
  id: 'modes', channelKey: '#mango', memberKey: 'alice',
  text: '@+', tooltip: 'Member modes +ov', color: 'accent'
}])

grove.channelBadges.set([{
  id: 'modes', channelKey: '#mango',
  text: '+int', tooltip: 'Channel modes +int', color: 'neutral'
}])

Each call atomically replaces the complete list; pass [] to clear it. A badge needs text or an SF Symbol icon. Colors use the same semantic names as status badges. Mango accepts at most two badges per target per extension and clears them when the Root stops.

Message filters

js
grove.filters.set({
  conversations: ['channel', 'query'],
  inbound: message => ({
    action: 'transform',
    displayText: message.displayText.replace(/^\[relay\] /u, ''),
    decorations: [{ id: 'relay', text: 'RELAY' }]
  }),
  outbound: message => ({
    action: 'transform',
    wireText: message.wireText.trim(),
    displayText: message.displayText.trim()
  })
})

conversations optionally limits a filter registration to channel, query, or both. Omit it for both conversation kinds. Declare the narrowest set the extension actually handles: a private-message protocol Root should use conversations: ['query'], which also lets Mango keep native IRCv3 multiline sends on channels outside the filter.

A filter returns allow, deny, or a structured transform. Inbound filters may set a presentation-only displaySource; the authoritative IRC source remains unchanged. wireText is what IRC receives and displayText is what Mango renders. Decorations are bounded, plain host-rendered badges.

Every filter message also carries host-owned conversation (channel or query) and delivery (live, history, or replay) fields. Optional localNickname is the user’s authoritative current nickname on the network for both directions once registration has supplied it, and localNicknameKey is its host-canonical form under the negotiated IRC CASEMAPPING; inbound query source and target identify the peer. Use targetKey, sourceKey, and localNicknameKey as opaque canonical identifiers instead of applying JavaScript case conversion. Do not infer a channel from familiar prefix characters because IRC servers negotiate CHANTYPES. Stateful protocol handlers must advance sessions and produce network responses only for delivery === 'live'; history is server-delivered backlog and replay is Mango re-evaluating an existing transcript after filters change. Optional account and host fields identify the sender when the IRC server provides them.

For live PRIVMSG and ACTION input, Mango evaluates cross-buffer previews and native local notifications only after the complete inbound filter chain resolves. A transformed message uses its final displayText; a denied message or hidden protected packet produces no preview or notification. The raw server console and chat-log pipeline remain wire-authoritative and therefore retain wireText rather than decrypted display text.

Outbound filters may make confirmation optional for the user by returning confirm instead of transform:

js
grove.filters.set({
  outbound: message => ({
    action: 'confirm',
    text: message.displayText.replaceAll('teh', 'the'),
    prompt: 'Review the corrected message before sending.'
  })
})

confirm applies the transform and asks Mango to pause the send in a host-owned review card. text is shorthand for changing both wireText and displayText; use those fields separately only when the bytes sent to IRC intentionally differ from the text Mango renders. prompt is optional, plain text, and bounded by the host. Extensions cannot provide the card’s controls or approve the send themselves.

Mango runs every remaining outbound filter before presenting the card, so the user reviews the final result of the complete chain. A later valid deny still blocks the message. If the user approves, Mango sends the captured final result once without running the filters again and preserves the original target and reply context. If the user cancels, Mango restores the original draft when it is still safe to do so. Immediate allow and transform behavior is unchanged, so confirmation remains opt-in. confirm is outbound-only; Mango ignores it in an inbound filter.

Ordinary filters are fail-open. A timeout, crashed WebView, thrown handler, or unusable result allows the message rather than silently losing it. Only an explicit valid deny drops a message. The separately consented protected-conversation policy below is the deliberate exception.

Protected message transforms

An extension separately granted messages.protect may return a terminal protect result from an ordinary message filter:

js
return {
  action: 'protect',
  outboundMessages: ['?OTR,...,1,2,...,', '?OTR,...,2,2,...,'],
  displayText: message.displayText,
  decorations: [{ id: 'encrypted', text: 'OTR' }]
}

For an outbound channel or query, outboundMessages replaces the logical plaintext with at most 32 ordered IRC message bodies. Mango validates the complete set against CTCP and IRC line limits and sends all frames in order or none. Set hidden: true with no displayText for a protocol-control send that must not create a transcript row; ordinary protected user text must provide a nonempty displayText. The protected result terminates the chain, so a later formatter cannot rewrite ciphertext. For a live inbound query, the same field may contain protocol responses to the same peer and hidden: true consumes a handshake/control packet without creating a row. Mango discards response frames from inbound channels and from history and replay evaluations. protect is ignored for ACTION and NOTICE or without messages.protect; ordinary filters remain backward-compatible and fail-open.

Once negotiation starts, publish the complete active conversation list:

js
grove.protection.set([
  { target: 'Alice', protocol: 'OTRv3', status: 'negotiating' },
  { target: 'Bob', protocol: 'OTRv3', status: 'verified' }
])

This host-owned list is an idempotent replacement; pass [] only after every protected session has ended. Mango persists it per network. Query policies require their owner to return protect in both directions. Channel policies require it for outbound ordinary messages only, preventing a filter failure from leaking plaintext while allowing unencrypted posts from other channel participants. A channel policy therefore does not assert that every inbound message is encrypted: plain posts remain visible without a protection decoration, and extensions must add their protocol decoration only after successful authentication. If the WebView crashes, stops, times out, loses its filter registration, or has not restarted yet, Mango blocks the protected send rather than falling back to plaintext. Loaded transcript rows in every protected query are also re-evaluated when policies restore or the owning filter changes, even when another buffer is selected; an unavailable owner hides those rows from presentation until protection is ready. grove.send(...) enters the same chain, and NOTICE is unavailable for a protected query, so another Sprig cannot bypass the policy. Removing the owning extension clears its policies.

When Mango receives an IRC NICK change for an open query, it emits queryRenamed with { oldTarget, oldTargetKey, newTarget, newTargetKey, outcome } to server-scoped protected-message Roots so nickname-keyed state can follow the peer. outcome is renamed when Mango moved the query and its policy to the new nickname, resetting the status to negotiating; it is collision when the destination query was already open and Mango left both buffers in place. Collision events let security extensions disable ambiguous keys and require explicit user resolution instead of continuing with stale identity. Events are delivered in the order Mango applies the accepted IRC nickname changes. oldTarget and newTarget preserve the display nicknames; oldTargetKey and newTargetKey are normalized with the network’s negotiated IRC CASEMAPPING and are the fields extensions should use for lookup. An extension that finds a collision in its separately persisted state must retain both identities, fail closed, and require explicit user resolution. Mango awaits asynchronous rename handlers and advances the delivery cursor only after success; transient WebKit failures are retried, and an undelivered event remains queued across WebContent reload.

The policy records only target, protocol label, and status; cryptographic keys remain in the extension’s secure storage. A Sprig with messages.protect receives a separate two-second per-message host deadline for authenticated key-exchange work; ordinary filters keep the 150 ms budget.

See relay-formatter v0.1.2 for inbound presentation transforms and member-menu actions. fish-root v0.1.2 demonstrates coordinated inbound and outbound transforms with Keychain-backed configuration.

Composer completion

Only an exclusive composer utility may call:

js
grove.composer.complete('https://example.com/result')
grove.composer.close()

Completion writes bounded draft text and closes the utility. It never sends the message.

Uploads, secrets, and logging

Upload handlers are documented in Upload providers. Keychain storage is documented in Storage and settings.

Use grove.log(message, level?) for a diagnostic routed to Mango’s extension console. level may be log, info, warn, error, or debug and defaults to log, so the one-argument API remains compatible. console.log, console.info, console.warn, console.error, console.debug, and uncaught JavaScript errors are captured there too. The extension’s Console view remains filtered to that one running instance. Mango’s status bar also opens a global Grove Console that combines output from every loaded extension and labels each line with its source. Do not include access tokens, message bodies, keys, ciphertext, or other private data in logs.