npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@arcadiascience/app-feedback-toolkit

v0.4.0

Published

In-app feedback for Arcadia internal apps. Reports become Linear issues, with a review page, MCP tools, and a Linear setup wizard.

Readme

@arcadiascience/app-feedback-toolkit

In-app feedback for Arcadia's internal web apps. A person sends a bug report or a request from the app or from an AI agent. It becomes an issue in Linear, and when the issue closes the app can tell the person.

The package holds everything an app needs:

  • A "Send feedback" dialog.
  • An admin page that lists reports and shows each one.
  • A page where each person sees the reports they sent.
  • A setup wizard that connects the app to Linear and checks that the whole path works.
  • The Linear webhook that reports a closed issue back to the app.
  • MCP tools (send_feedback, list_feedback, get_feedback) for apps that run an MCP server.

Reports are stored in Linear only. The app's database keeps the Linear connection settings, plus the issue ID and result of the setup wizard's latest test report.

When an app turns on the software factory setting, each new report also carries the User feedback label, which starts the software factory on the issue, and a Reporter email: line for the engineers who work on it.

What the app needs

The package is built for the standard Arcadia stack and does not try to fit anything else.

| Needs | Version | | --- | --- | | Next.js, App Router | 16 | | React | 19 | | Drizzle ORM with Postgres | 0.45 | | Zod | 4 | | nuqs | 2.8 or later | | Tailwind CSS with shadcn theme variables | 4 | | @modelcontextprotocol/server, only for the MCP tools | 2 |

The app keeps its own login, user table, and notifications. It tells the package about them in one place (step 2 below).

Set up an app

1. Install, and add the tables

npm install @arcadiascience/app-feedback-toolkit

Re-export the package's two tables from the schema file, then generate and run a migration the way the app always does:

// db/schema.ts
export * from "@arcadiascience/app-feedback-toolkit/drizzle";

The tables are linear_integration_config (one row: the encrypted Linear credentials, the chosen team, project, and labels, the software factory setting, the webhook's health, and the wizard's latest test report) and linear_webhook_deliveries (stops Linear's repeat deliveries from notifying twice). updated_by is a plain user ID with no foreign key.

After you bump the package version, generate a migration the way the app always does. A release that adds a column to these tables says so in its notes.

2. Tell the package how the app works

Make one shared module. Every other file imports feedback from it.

// lib/feedback.ts
import { createAppFeedback } from "@arcadiascience/app-feedback-toolkit/server";

export const feedback = createAppFeedback({
  appName: "Labkeep",
  db,
  origin: () => appOrigin(),
  secretsKey: () => process.env.INTEGRATION_SECRETS_KEY,

  // Who is signed in on the web. Return null for machine credentials such as
  // personal access tokens: a report belongs to the person who wrote it.
  getViewer: async (request) => {
    const user = await currentUser(request);
    return user && { id: user.id, name: user.name, email: user.email, isAdmin: user.isAdmin };
  },
  // Who is behind an MCP request. `authInfo.extra` is whatever the MCP
  // transport's token check put there.
  getMcpViewer: async (authInfo) => {
    const userId = authInfo?.extra?.userId;
    return typeof userId === "string" ? viewerById(userId) : null;
  },

  // People, by ID and by email. Missing people are left out.
  getUsersByIds: (ids) => db.select(/* id, name, email, image */).from(users).where(inArray(users.id, ids)),
  findUsersByEmails: (emails) => db.select(/* same columns */).from(users).where(inArray(lower(users.email), emails)),

  // Optional. Shows "sent through Claude" next to a report.
  getOauthClientNames: async (clientIds) => ({ /* clientId: name */ }),

  // Optional. The app sends its own notifications, in its own way.
  onReportSubmitted: async (report) => { /* tell the admins */ },
  onReportClosed: async (report) => {
    // Return what happened. The setup wizard shows it for its test report.
    //   "delivered": the reporter was told.
    //   "disabled":  the reporter turned this notification off.
    //   "none":      the app sent nothing.
    return "delivered";
  },

  // Optional wording for the MCP tools.
  mcpAppScope: "the web app or its MCP tools",
  mcpGuidance: "Problems with one record belong in a comment on it.",
});

origin is this deployment's public address, such as https://labkeep.example.com. It decides which reports this deployment shows (see Reports from other deployments). secretsKey is 64 hex characters, made with openssl rand -hex 32, and encrypts the Linear secrets in the database. If the app keeps it in a variable not named INTEGRATION_SECRETS_KEY, pass that name as secretsKeyName, so error messages and the setup wizard tell people which variable to set.

All options are in src/server/config.ts.

3. Add the routes

One catch-all file serves everything under /api/feedback:

// app/api/feedback/[[...path]]/route.ts
import { createFeedbackRoutes } from "@arcadiascience/app-feedback-toolkit/next";
import { feedback } from "@/lib/feedback";

export const { DELETE, GET, POST, PUT } = createFeedbackRoutes(feedback);

Linear stores a link to each report, and opening it must land on the report. Add the redirect at /feedback/r/…:

// app/feedback/r/[...path]/route.ts
import { createFeedbackHandlers } from "@arcadiascience/app-feedback-toolkit/next";
import { feedback } from "@/lib/feedback";

export const { GET } = createFeedbackHandlers(feedback, {
  reviewPath: "/settings/feedback",
}).reportRedirect;

If the app's middleware asks people to sign in, the redirect works either way. A person who is not signed in is sent to sign in first.

An app that already has its own URLs can mount each handler separately. createFeedbackHandlers(feedback) returns feedback, linearSettings, linearConnect, linearOptions, linearTestReport, linearTestReportStatus, linearWebhook, and reportRedirect. Each has the method handlers a route file exports.

| Standard path | Handler | | --- | --- | | GET, POST /api/feedback | feedback | | GET, PUT, DELETE /api/feedback/linear | linearSettings | | POST /api/feedback/linear/connect | linearConnect | | GET /api/feedback/linear/options | linearOptions | | POST /api/feedback/linear/test-report | linearTestReport | | GET /api/feedback/linear/test-report/:id | linearTestReportStatus | | POST /api/feedback/linear/webhook | linearWebhook |

4. Add the provider and the menu item

// app/layout.tsx
import { FeedbackProvider } from "@arcadiascience/app-feedback-toolkit/react";
import { NuqsAdapter } from "nuqs/adapters/next/app";

<NuqsAdapter>
  <FeedbackProvider appName="Labkeep" notificationsHref="/settings/notifications">
    {children}
  </FeedbackProvider>
</NuqsAdapter>

The review page and the wizard keep their state in the URL, so the app needs NuqsAdapter. FeedbackProvider brings its own tooltip provider. Its options are in src/react/context.tsx. If the app's routes are not under /api/feedback, set feedbackUrl and linearUrl there.

Two more options shape the dialog:

  • placeholders sets the example text in the form's fields, so the examples fit the app. Set any of title, description, attemptedAction, toolName, and errorMessage. A field you leave out keeps a neutral example.
  • reportsHref is the page where a person sees their own reports, such as "/feedback" (see step 5). When it is set, the dialog links to the report a person has just sent, which opens on that page.

describePage turns the page a report was sent from into a readable name in the review modal, such as "Plate reader, run 42" instead of the last part of the URL. It is a function, and a server component cannot pass a function to a client component. An app that sets it mounts the provider from a small client component of its own, and renders that from the layout:

// components/app-feedback-provider.tsx
"use client";
import { FeedbackProvider } from "@arcadiascience/app-feedback-toolkit/react";

function describePage(segments: string[]): string | null {
  return segments[0] === "records" && segments[1] ? `Record ${segments[1]}` : null;
}

export function AppFeedbackProvider({ children }: { children: React.ReactNode }) {
  return (
    <FeedbackProvider appName="Labkeep" describePage={describePage}>
      {children}
    </FeedbackProvider>
  );
}

Put a "Send feedback" item wherever the app likes. The package does not ship one, because a menu item has to be built with the same menu library as the rest of the menu:

"use client";
import {
  preloadFeedbackDialog,
  useOpenFeedback,
} from "@arcadiascience/app-feedback-toolkit/react";

export function FeedbackMenuItem() {
  const openFeedback = useOpenFeedback();
  return (
    <DropdownMenuItem
      onFocus={preloadFeedbackDialog}
      onPointerEnter={preloadFeedbackDialog}
      onSelect={openFeedback}
    >
      Send feedback
    </DropdownMenuItem>
  );
}

Show it only when feedback is set up: await feedback.isConfigured().

The dialog and its form download the first time someone opens the dialog, so they add nothing to the pages that never use it. preloadFeedbackDialog starts that download early, such as when the pointer reaches the menu item, so the dialog opens with no wait. Calling it more than once is fine.

5. Add the admin pages, and a page for each person's own reports

The app checks that the person is signed in and is an admin. The package draws the rest.

// app/(app)/settings/feedback/page.tsx
import { FeedbackReviewPage } from "@arcadiascience/app-feedback-toolkit/next";

<FeedbackReviewPage feedback={feedback} searchParams={await searchParams} viewerId={user.id} />
// app/(app)/settings/integrations/page.tsx
import { LinearSetupSection } from "@arcadiascience/app-feedback-toolkit/next";

<LinearSetupSection feedback={feedback} />

LinearSetupSection shows the webhook address people paste into Linear. It assumes the standard path, so pass webhookPath if the webhook is mounted somewhere else.

A third page is for everyone who is signed in. It lists the reports a person sent, with the same Open and Closed tabs, and opens one in a modal. It never shows anyone else's report, and it leaves out what Linear knows about it (assignee, priority, activity, and the link to Linear), because those stay with admins. The app only checks that someone is signed in:

// app/(app)/feedback/page.tsx
import { MyFeedbackPage } from "@arcadiascience/app-feedback-toolkit/next";

<MyFeedbackPage feedback={feedback} searchParams={await searchParams} viewerId={user.id} />

Pass reportsPath if the page is not at /feedback, and set the same path as reportsHref on the provider. A person can open their own report only when getUsersByIds returns their row, so make it return a row for every account, including people with no profile.

6. Add the styles

In the app's Tailwind CSS file:

@import "@arcadiascience/app-feedback-toolkit/styles.css";

That one line makes Tailwind read the package's class names and adds the table's loading animation. The components use the app's own theme variables (--background, --muted, --border, and so on), so they match the app's look.

7. Add the MCP tools

import { registerFeedbackTools, feedbackInstructions } from "@arcadiascience/app-feedback-toolkit/mcp";

createMcpHandler((server) => {
  registerFeedbackTools(server, feedback);
}, { instructions: `${APP_INSTRUCTIONS}\n\n${feedbackInstructions(feedback)}` });

send_feedback needs no more than a read-only token, so every connected agent can report a problem.

An app with a public tool catalog can build the same definitions without a database connection, from three strings:

feedbackToolDefs({ appName: "Labkeep", appScope: "…", guidance: "…" }).all;
feedbackInstructions({ appName: "Labkeep", guidance: "…" });

8. Connect Linear

An admin does this in the setup wizard on the integrations page. It walks through creating a Linear OAuth app with client credentials turned on, choosing the team, project, and labels, adding the webhook, and sending a test report.

Before the first run, create these in Linear:

  • A label named User feedback in the team, if the app will start the software factory.
  • Optionally, a label for each kind of report (bug, feature request, other).

Reports from other deployments

Every report's link in Linear starts with the web address of the deployment that filed it. A deployment lists, opens, and reacts to the closing of only the reports whose link starts with its own address. Staging and production can share one Linear team without seeing each other's reports.

An app that changes its address keeps its old reports visible with extraOrigins:

createAppFeedback({ origin: "https://new.example.com", extraOrigins: ["https://old.example.com"], /* … */ });

The software factory

The wizard has a Start the software factory for new reports setting. It is saved separately for each deployment and is off by default, so staging and preview deployments never start the factory. A production deployment turns it on.

When it is on, each new report gets:

  • the User feedback label (the wizard checks that the team has it), and
  • a Reporter email: line in the issue description. The factory doesn't use it and never contacts the reporter. The line is there for the engineers who work on the report, who may need to check something with the reporter.

A report from someone with no email address in the app gets neither, because an engineer working on it would have no way to reach the reporter. The wizard's test report never carries the label either.

Only the address where it was turned on

A preview deployment often has a copy of production's database, and with it production's saved Linear setup. So the setting remembers the web address of the deployment where an admin turned it on, and only reports sent from that address start the factory. The wizard shows the address next to the setting. On any other deployment it says that reports sent there do not start the factory.

  • Saving other settings never moves the address. To move the factory to another address, turn it off and on again from that deployment.
  • Turning it off forgets the address.
  • A setting saved before this version has no address. It keeps starting the factory from every deployment that uses it until an admin turns it on again, which records the address. Do that from production after upgrading.

The label name and the description format live in src/contract.ts. The factory imports that file, so a change to the format reaches both sides in one pull request. The file has no imports so the factory's own tsconfig.json can compile it.

Moving reports an app already has into Linear

An app that kept feedback in its own table can move those reports into Linear once, when it adopts the package. feedback.importFeedback(reports, { apply }) files each report as an issue in the saved team and project:

  • The reporter, the date it was sent, and its kind are kept. The issue is created as the reporter, with the kind's label and the date the report was sent.
  • A resolved report is created in the team's first completed state, and a declined one in its first canceled state. An open report goes to the team's default state.
  • An admin's note to the reporter becomes a comment on the issue, starting "Note to the reporter from …".
  • Each report has an importId, such as its ID in the app's old table. A report that is already in Linear is skipped, so a run that stopped halfway can be run again, and a report that was created but not yet linked is finished without a second comment.
  • Nothing is announced. It never calls onReportSubmitted, never adds the software factory label, and an issue created already closed sends no closing update, so no reporter is told.

It writes nothing unless apply is true. Without it, it returns what it would do, so a script can show a preview first. It stops before writing anything if two reports share an importId, if the app does not know one of the reporters (getUsersByIds returns no row), or if the team has no state to put closed reports in. If Linear fails partway, the result says which report it stopped at, and the reports before it are already filed.

const result = await feedback.importFeedback(
  rows.map((row) => ({
    importId: row.id,
    userId: row.reporterUserId,
    kind: row.kind,
    title: row.title,
    description: row.description,
    source: row.source,
    status: row.status, // "open", "resolved", or "declined"
    createdAt: row.createdAt,
    closedAt: row.statusUpdatedAt,
    note: row.adminNote
      ? { text: row.adminNote, authorName: row.reviewerName, at: row.statusUpdatedAt }
      : null,
  })),
  { apply: process.argv.includes("--apply") }
);

It does not need a Next.js request, so a plain Node script can run it. Connect Linear in the setup wizard first, because the import uses the saved team, project, and labels.

Testing an app that uses the package

@arcadiascience/app-feedback-toolkit/testing exports startLinearFakeServer(), a stand-in for Linear's OAuth and GraphQL API. Point the app at it with __TEST_LINEAR_API_URL. This package's own integration tests use it with the app in example/.

Working on the package

npm ci
npm run check            # lint, format, and types
npm run test:unit
npm run test:integration # needs Postgres on 127.0.0.1:5432
npm run dev:example      # the example app, for working on the UI

npm run dev:example loads the package from src/, so a change to the UI shows up on refresh. Open http://localhost:3000/demo for the review page and the report modal with sample reports. That page needs no database and no Linear connection. /demo/my-reports is the page a person sees for their own reports. next build, which the integration tests use, still loads the built files in dist/.

The integration tests build the package, build the example app against the built files, and run it over HTTP with a fake Linear server. Set TEST_PG_URL to use a Postgres other than postgres://postgres:[email protected]:5432.

A pull request that changes what is published (src/, package.json, README.md, LICENSE, or tsconfig.build.json) needs a release note, and CI fails without one. Changes to the tests, the example app, or CI need none.

npm run changeset

Choose patch for a fix, minor for a new option, and major for a change that makes an app edit its code. Write the note for the person who owns an app that uses the package. A change that should not cause a release on its own, such as a typo in this README, can add an empty note with npx changeset --empty.

How a release happens

  1. A pull request with a changeset merges to main.
  2. The release workflow opens or updates a "Version app-feedback-toolkit" pull request.
  3. Merging that pull request publishes to npm and creates a GitHub release.

Each app updates by hand: bump @arcadiascience/app-feedback-toolkit, read the release notes, and add a changelog entry in the app if people will see a difference.

One-time setup for publishing

Publishing uses npm trusted publishing, so no npm token is stored in the repository. npm lets a package add a trusted publisher only once the package exists, and a new trusted publisher expires if it has not published within 2 days (see npm's documentation). So the first release goes in this order:

  1. On npmjs.com, create the arcadiascience organization, or confirm Arcadia already owns it.
  2. In this repository's settings, under Actions → General, turn on Allow GitHub Actions to create and approve pull requests, so the release workflow can open its pull request.
  3. Merge the pull request that adds the package. The release workflow opens a "Version app-feedback-toolkit" pull request for 0.1.0. Leave it open for now.
  4. Reserve the name. From app-feedback-toolkit/ on an up-to-date main, with an account in the organization, run npm ci, npm run build, and npm publish --access public. That publishes version 0.0.0. Do not run npm run version first: the version pull request has to be what publishes 0.1.0, or the trusted publisher never gets its first publish.
  5. In the package's settings on npmjs.com, add a trusted publisher for GitHub Actions: organization Arcadia-Science, repository software-factory, workflow file app-feedback-toolkit-release.yml. Tick Allow npm publish. A trusted publisher created after September 3, 2026 allows only npm stage publish unless that box is ticked, and the workflow runs npm publish.
  6. Within 2 days, merge the "Version app-feedback-toolkit" pull request. The workflow publishes 0.1.0, which is the trusted publisher's first publish. If 2 days pass first, delete the trusted publisher, add it again, and merge within 2 days.

The "Version app-feedback-toolkit" pull request is opened with the repository's GITHUB_TOKEN, and GitHub does not run checks on pull requests opened that way. If that is a problem, give the release workflow a token from a GitHub App instead. npm does not attach provenance to versions published from a private repository, so this package's versions have none.