Skip to main content
A work destination turns a mobile Review-mode thread into a linked provider item. Start with tappify extension add work, then implement the generated work handlers and provider webhook adapter with @tappify/extension-sdk@2.1.0-beta.1. This contract is implemented behind a disabled-by-default runtime flag. The SDK beta is published; the later implementation’s focused/runtime suites, repository release gates, and internal staging acceptance remain pending. The examples describe the contract, not a completed production rollout.

Review threads and customer reviews

Work destinations use the Review SDK: teammates pin feedback to a screen in the owner’s iOS or Android app and continue the conversation on the host’s Review page. review.threads:read grants the scoped thread snapshot, comments, screenshot descriptors, build context, and safe device context. reviews:read grants App Store and Google Play customer-review data instead. Customer reviews and public store replies are outside this work protocol.

Declare capabilities and authentication

The following fragment shows Work Bridge’s capability and scope shape. Add the usual extension metadata and a deployed server as described in the manifest reference and server guide.
kind is tracker, docs, or chat; creates contains issue, page, or message. Those capabilities describe the extension. Exact provider item types come from container discovery. Current work manifests need review.threads:read and work:create; any comment or status direction other than none also needs work:sync. Grants must exist on the install, not only in the manifest. Inbound writes only affect an established link under work:sync; there is no general extension scope for writing arbitrary Review threads. Each install uses one provider identity. Provider writes use that connection’s credentials, even when the content includes a teammate’s display name. identity: "user" is rejected. Work auth is either API-key credential fields or { "method": "oauth", "provider": "provider-id", "scopes": [] }. Provider OAuth scopes differ from Tappify scopes. Configure vendor OAuth endpoints, client credentials, authorization/token parameters, PKCE, and resource discovery in the developer portal; client secrets belong in the vendor vault. Install credentials remain in the host vault and arrive on authenticated work calls. Do not put either credential set in manifests, logs, or callback records. Comment and status directions are independent: The owner can turn new-comment synchronization off. Comments are append-only: edits, deletions, reactions, and comment attachments do not synchronize. Provider comments carry the extension’s identity and the provider author’s display name; they do not impersonate a Tappify teammate or resolve provider mentions as Tappify mentions. The SDK v2 compatibility window still accepts complete legacy manifests with static container, fields.required, string-valued comments, and status.map, with migration warnings. Legacy reviews:read validation leniency does not grant Review-thread access at runtime. Migrate to the Review-specific scope, auth, capability objects, and dynamic metadata. Legacy status and resolve handler routes remain deprecated; a missing set_status can fall back to resolve for resolved targets only, with externalItemId adapted to id. Implement set_status for reopen as well. Doctor checks auth, unsupported identity, scopes, deprecated fields, and a public HTTPS production server.baseUrl for active sync. The CLI emits typed stubs and a webhook placeholder; it does not implement provider delivery or durable persistence for you.

Discover and configure a destination

After connecting, the owner selects a container on the install page. list_containers supports cursor, query, and parentId; pass opaque cursors unchanged. describe_container returns the container, item types, create fields, and current statuses for the selected container. Each create field has an ID, label, type (string, number, boolean, or string[]), required flag, optional choices, and optional itemTypeIds restrictions. The host validates supplied values against fresh metadata. Manual configuration may omit required create values; creation still requires valid provider fields. Automatic mode requires every applicable required value before the host accepts configuration. Both modes require a current item type and valid exact status targets. The host sends the exact selected providerStatusId on set_status, rather than guessing from a label or global manifest map. A changed provider field or unavailable target needs corrected configuration; invalid automatic configuration is rejected, and delivery validation failures can surface as configuration_required. In manual mode the thread drawer offers Create in for an eligible destination. In automatic mode a newly committed thread queues creation; a failed automatic create can also be recovered from the drawer. Existing links are returned instead of creating another item. Once linked, comments and status follow their configured directions in either creation mode. Accepted configuration has a configurationRevision. Callback provisioning has a generation; secret rotation can advance the generation without changing the owner’s revision. configure must acknowledge both exact values and return registeredWebhookIds. Fence stale generations and revisions so a delayed configure or disconnect cannot overwrite the current registration. The host also checks accepted configuration and queued status targets when retrying.

Implement the nine operations

Register typed handlers under createTappifyHandler({ extensionId, work }). Calls use POST /tappify/work/<operation> and the existing authenticated install request, with current credentials and typed input. See WorkOperationMap for the full input/output contract. Mutations (configure, create, attach, comment, set_status, disconnect) carry an idempotencyKey and ISO 8601 occurredAt. Operations on an existing item carry linkId, externalItemId, and threadId; create carries the thread identity inside source. Persist operation results per install and operation before acknowledging delivery. Reuse the original result on retry and reject a key reused for a different logical mutation. eventId is the authenticated request’s delivery identity; do not substitute a new transport ID for the operation’s stable idempotency key. ReviewThreadSource contains thread/project/app IDs, nullable build version/number, screen identity, normalized pin coordinates, the first comment, safe device context, and a Tappify deep link. Its optional screenshot descriptor carries a short-lived URL, attachment ID, media type, and file name. The host refreshes download URLs on retries; never use a URL as mutation identity. Create returns a stable item, the host persists its link, and separate attach calls add the deep link and screenshot. Retrying an attachment must preserve the item and attachment identities.

Provision callbacks and verify provider webhooks

configure supplies the exact per-install callbackUrl and signing secret. Store the callback secret encrypted alongside provider webhook routing state. Register provider webhooks against an opaque route belonging to that install and destination; return their IDs. Keep the provider’s webhook-verification secret separate from the Tappify callback secret. Verify the provider signature against the exact raw request bytes before parsing or using the payload for routing. Follow that provider’s signing/replay rules. Parsing and reserializing JSON before verification changes the signed bytes. The backend remains provider-neutral: signature formats, provider APIs, normalization, and provider webhook lifecycle belong to the extension. On callback-secret rotation the host provisions another configure generation. Replace routing and callback state atomically, invalidate stale references as appropriate, and continue using the new callback/secret. The old callback signature no longer authenticates against the current host secret. Never infer a callback URL or copy a shared secret from another install. Normalize provider signals to comment.created, status.changed, or item.deleted and call sendWorkEvent. The helper validates the event, serializes it once, signs those UTF-8 bytes with HMAC-SHA-256, and posts to the configured callback with x-tappify-signature. These events use the dedicated work callback, independently of declared alert/banner/chart webhooks and sendEvent. Every event has externalItemId, providerEventId, dedupeKey, ISO 8601 occurredAt, and optional providerVersion. Comments include stable externalCommentId, body, author ID/display name, and creation time. Status events include a full { id, label, stage }. Deletion needs no content. Keep one canonical dedupe identity per logical provider signal across retries, webhook echoes, and callback rotation. The host deduplicates by (installId, dedupeKey) and provider comment mapping. The production Trello adapter persists one opaque event reference for each provider action and reuses it as the dedupe key through durable delivery and hydration. Do not derive identity from receipt time, callback generation, or a randomly generated retry ID. For identifier-only webhooks, send a nonempty, short-lived hydrateRef in place of normalized comment/status content. The host resolves the owned link, then calls hydrate_event with fresh vault credentials. Return the complete event with exactly the signed type, externalItemId, providerEventId, dedupeKey, occurredAt, and providerVersion. Bind the reference to the install, item, owning thread, generation, and expiry. Hydration does not authorize a different event or require the extension to retain provider access/refresh tokens for webhook processing.

Delivery, conflicts, and recovery

The host commits Review changes and durable outbox intent together, then performs provider I/O from background jobs. Provider downtime does not roll back the original Review action. Per-install delivery state and bounded retries keep one destination’s failure from replaying successful peers. The relay makes one attempt; the durable worker handles retries. sendWorkEvent performs no retry loop. It rejects non-2xx callbacks with WEBHOOK_REJECTED, preserving the HTTP status in TapServerError. The adapter must arrange durable redelivery for transient/network failures, processing conflicts, rate limits, and server errors with the same event identity. Fix permanent signature, scope, ownership, configuration, or schema rejections before redelivery. The current intake limit is 60 events per minute per install. Return the exact provider comment ID and status version from outbound operations so webhook echoes carry the same identities. The host stores comment mappings and provider status metadata, suppresses duplicate visible comments, and excludes an inbound change’s source link from outbound fanout. Delivery is at least once; visible inbound comments are deduplicated. Prefer monotonically increasing numeric or dotted-numeric providerVersion strings; the host can compare these and reject older status versions. Opaque versions are not lexically ordered. Provider occurrence time is compared with the thread’s status observation time; newer observations win. Equal or unknown observation times that disagree produce STATUS_TIMESTAMP_CONFLICT instead of choosing a side. Provider metadata only advances monotonically; an unrelated comment does not silently clear a status conflict. There is no general manual-resync operation in this release. Native work chips show the extension, provider display key, normalized state, and sync health: healthy, pending, auth_required, configuration_required, failed, or external_deleted. The drawer offers retry for failed linked delivery, while pending work stays queued. Retry preserves the existing link and does not call create again. Reconnect or correct configuration for the corresponding state. A conflict is not guaranteed to clear by pressing retry on an outbound job. item.deleted marks the link external_deleted. The host retains its identity, ignores later comment/status changes for that deleted link, and prevents retry/recreation of the deleted item. Uninstall attempts disconnect while credentials are available; cleanup failure is recorded but does not prevent revocation. Remove callback secrets, routes, and hydration references. Provider items need not be deleted; retain idempotency history according to your lifecycle policy and expire unreachable provider webhooks through provider-owned cleanup.

Persistence boundaries

Tappify owns durable configuration revisions/generations, Review-to-provider links, comment mappings, inbound event claims/deduplication, outbox intent, and per-install delivery/retry state. Links are unique by install/thread and install/provider item. The host draws link chips from these records. The extension owns provider API behavior, encrypted callback registrations, provider webhook routing, operation idempotency results, cursors, and hydration references. Use transactional durable storage for these in a deployed extension. Hosted extension storage can hold settings and UI state, but extension servers cannot write it as a background ledger; extension storage alone is not the synchronization source of truth.

Study the production Trello adapter

The production reference lives in tappify-dev/extensions, under apps/trello. Start with its README.md, then read server/work.ts, server/webhook/handler.ts, server/webhook/normalize.ts, and server/delivery.ts.
  1. list_containers and describe_container discover accessible Trello boards and active lists. Configuration records one board, distinct open and resolved lists, and whether comments sync.
  2. create, attach, comment, and set_status use durable idempotency records. Ambiguous provider responses reconcile through stable markers and provider identities instead of repeating a visible mutation.
  3. Trello signs the untouched request body plus the exact registered callback URL. The adapter verifies that signature before parsing, normalizes supported actions, and commits an opaque hydration reference before attempting delivery.
  4. Durable delivery retries the same event identity. hydrate_event fetches current provider content with fresh install credentials and returns the complete comment or status event.
  5. Configure generations and revisions fence stale routes. Disconnect deletes the known Trello webhook before removing its route and synchronization records.
For an isolated sender example, inject a transport that accepts the request in memory. The URL, secret, and IDs below are fixtures; this code makes no network call and does not exercise host intake.
The testing helpers also expose signedWorkEventRequest and fixtures for each event type. See Testing with the SDK and Local server for the surrounding development workflow. The Trello package’s focused suites cover work operations, webhook signatures and normalization, durable delivery, hydration, retries, and disconnect behavior. Its README records the redacted staging acceptance evidence and remaining provider limitations.