Close the loop between humans and AI agents.
Capture UI feedback — screenshots, clicks, errors, context — and feed it
straight back to the agent that built the interface.
npm install @microsoft/snapfeed
Quick Start · How It Works · Server · Configuration · Plugins
AI agents can write UI code, but they can't see the result. Snapfeed gives them eyes. Drop one line into your app and every interaction — clicks, navigation, errors, and annotated screenshots — flows into a structured telemetry feed that an agent (or a human) can query.
The agent writes code. You test. When something's off, Cmd+Click anywhere to capture an annotated screenshot with full page context. The agent reads the feedback, fixes the code, and you test again.
graph LR
A["Agent\nwrites code"] --> B["Your UI\n(with snapfeed)"]
B --> C["You test it\nCmd+Click feedback"]
C --> D["Agent\nreads feedback"]
D -- "fixes & iterates" --> A
Ship snapfeed in your production app. Real users submit feedback with categorized tags (🐛 Bug · 💡 Idea · ❓ Question · 🙌 Praise). Feedback accumulates in a queue. An agent — or your dev team — triages and acts on it.
graph LR
A["Users\nin prod"] --> B["Snapfeed\nserver"]
B --> C["Queue\n(SQLite)"]
C --> D["Agent /\nDev team"]
Requires Node.js 20.19.0+ or 22.12.0+.
npm install @microsoft/snapfeedimport { initSnapfeed } from "@microsoft/snapfeed";
initSnapfeed(); // that's it — Cmd+Click to send feedbackSnapfeed auto-captures clicks, navigation, errors, and API failures. No
config needed for local dev — events POST to /api/telemetry/events by default.
TypeScript (Hono + SQLite):
npx snapfeed-server
# 🔭 snapfeed-server listening on http://localhost:8420Python (FastAPI + SQLite):
cd examples/python && pip install -r requirements.txt
uvicorn server:app --port 8420Or mount into your own app:
import { snapfeedRoutes, openDb } from "@microsoft/snapfeed-server";
import { Hono } from "hono";
const app = new Hono();
app.route("/", snapfeedRoutes(openDb({ path: "./feedback.db" })));# List sessions
curl localhost:8420/api/telemetry/sessions
# Get events for a session
curl localhost:8420/api/telemetry/events?session_id=abc-123
# Get only feedback (Cmd+Click) events
curl localhost:8420/api/telemetry/events?event_type=feedback
# View a screenshot
curl localhost:8420/api/telemetry/events/42/screenshot --output feedback.jpg| Event | Trigger | Detail |
|---|---|---|
session_start |
initSnapfeed() |
Viewport, URL, user agent, plugins |
click |
Any click | Element tag, role, CSS path, coordinates, component name (via plugins) |
feedback |
Cmd+Click | Annotated screenshot, user message, category, console errors, page context |
navigation |
SPA route change | Path, hash, search params |
error |
window.onerror |
Message, filename, line, stack trace |
api_error |
fetch() non-2xx |
URL, status, method |
network_error |
fetch() failure |
URL, error message, method |
All events include session_id, seq, ts, page, and target.
| Package | Description |
|---|---|
@microsoft/snapfeed |
Client library — drop-in, framework-agnostic, zero config |
@microsoft/snapfeed-server |
Reference backend — Hono + SQLite, pluggable or standalone |
examples/react |
React integration example and Playwright-backed verification lab |
examples/python |
Python backend example — FastAPI + SQLite (~100 lines) |
The repo now includes a dedicated React app for end-to-end validation in examples/react. It is intentionally not a polished product demo. Its job is to exercise the full browser-to-database flow against a real SQLite file and make that flow easy to automate while also serving as a concrete React integration example.
The app covers:
session_starton bootclickevents from regular UI interactionnavigationevents from SPA route changesapi_errorandnetwork_errorvia explicit failing fetch flowserrorvia uncaught errors and unhandled rejectionsfeedbackvia the real Cmd/Ctrl-click dialog with screenshot and context
Use two terminals from the repo root:
npm run dev:react-e2e:server
npm run dev:react-e2eOpen the Vite app URL that prints in the terminal. The API server listens on http://127.0.0.1:8420 by default and writes to a local SQLite file under examples/react/.tmp/.
npm run test:react-e2eThe Playwright suite starts both the React app and the server harness, runs the browser flows, and verifies persisted DB rows.
The harness prefers the real @microsoft/snapfeed-server implementation. If the local better-sqlite3 native binding is unavailable, the dev server falls back to a node:sqlite compatibility server that preserves the same schema and endpoints so the React E2E workflow can still run on supported Node environments that expose the built-in SQLite module.
Both the TypeScript and Python servers implement the same 4 endpoints:
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/telemetry/events |
Ingest a batch of events |
GET |
/api/telemetry/events |
Query events (?session_id=, ?event_type=, ?limit=) |
GET |
/api/telemetry/sessions |
List sessions with event counts |
GET |
/api/telemetry/events/:id/screenshot |
Serve feedback screenshot as JPEG |
Building your own backend? Implement POST /api/telemetry/events accepting:
{
"events": [
{
"session_id": "a1b2c3",
"seq": 1,
"ts": "2026-03-19T18:00:00.000Z",
"event_type": "click",
"page": "/dashboard",
"target": "button.save",
"detail": { "tag": "button", "x": 420, "y": 300 },
"screenshot": null
}
]
}That's the only endpoint the client needs. The query endpoints are for you.
initSnapfeed({
// Where to send events (default: '/api/telemetry/events')
endpoint: "http://localhost:8420/api/telemetry/events",
// Batch settings
flushIntervalMs: 3000, // flush every 3s (default)
maxQueueSize: 500, // ring buffer size (default)
// What to capture
trackClicks: true, // click events (default)
trackNavigation: true, // SPA route changes (default)
trackErrors: true, // window errors + unhandled rejections (default)
trackApiErrors: true, // monkey-patch fetch() for non-2xx (default)
captureConsoleErrors: true, // buffer recent console.error output (default)
// Feedback flow (Cmd+Click)
feedback: {
enabled: true,
screenshotMaxWidth: 1200,
screenshotQuality: 0.6,
annotations: true, // let users draw on the screenshot
},
// Optional user identity
user: { name: "Jane", email: "jane@example.com" },
// Feedback UI theme
theme: {
panelBackground: "#f5f9ff",
panelText: "#12344d",
accent: "#0f6cbd",
accentContrast: "#ffffff",
},
// Adapters — fan out feedback to external systems
adapters: [webhookAdapter("https://hooks.slack.com/...")],
// Plugins — framework-specific enrichment
plugins: [reactPlugin()],
});Returns a teardown function: const teardown = initSnapfeed(); teardown()
Built-in presets are also available through the client package exports: modern, windows90s, terminal, githubLight, dracula, and nord. If you pass a custom theme object, Snapfeed merges it over the modern preset.
If you want Snapfeed's capture, queueing, adapters, and event contract without the built-in dialog, provide feedback.onTrigger.
import { initSnapfeed } from "@microsoft/snapfeed";
initSnapfeed({
feedback: {
enabled: false,
onTrigger(controller, trigger) {
openYourFeedbackModal({
anchor: { x: trigger.x, y: trigger.y },
initial: controller.getSnapshot(),
onTextChange: (text) => controller.setText(text),
onIncludeScreenshotChange: (include) =>
controller.setIncludeScreenshot(include),
onIncludeContextChange: (include) =>
controller.setIncludeContext(include),
onAnnotate: () => controller.annotate(),
onSubmit: () => controller.submit(),
onClose: () => controller.dispose(),
});
},
},
});You can also wire the trigger manually by using getFeedbackTrigger(event) and createFeedbackController(trigger) from the client package.
Custom UI still submits the same feedback event to the same telemetry endpoint. The payload shape and backend contract stay unchanged.
Plugins enrich click and feedback events with framework-specific context (component names, source file locations, etc.).
import { registerPlugin } from "@microsoft/snapfeed";
registerPlugin({
name: "react",
enrichElement(el) {
const fiber = (el as any).__reactFiber$; // simplified
return fiber ? { componentName: fiber.type?.name } : null;
},
});When a plugin is active, click events include component and source_file
in their detail — so your agent knows exactly which component was clicked.
Adapters deliver feedback events to external systems in addition to the telemetry endpoint. They run on every feedback (Cmd+Click) event.
import { consoleAdapter, webhookAdapter } from "@microsoft/snapfeed";
import {
githubAdapter,
slackAdapter,
telegramAdapter,
} from "@microsoft/snapfeed/adapters";
initSnapfeed({
adapters: [
consoleAdapter(), // log to dev console
webhookAdapter("https://my-api.com/hook"), // POST to a webhook
githubAdapter({
// create GitHub issues
token: process.env.GITHUB_TOKEN!,
owner: "my-org",
repo: "my-app",
labels: ["feedback", "from-user"],
}),
slackAdapter({
// post to Slack
webhookUrl: process.env.SLACK_WEBHOOK!,
}),
telegramAdapter({
// send to Telegram
botToken: process.env.TELEGRAM_BOT_TOKEN!,
chatId: process.env.TELEGRAM_CHAT_ID!,
}),
],
});| Adapter | Destination | Screenshot |
|---|---|---|
consoleAdapter() |
Dev console | — |
webhookAdapter(url) |
Any HTTP endpoint | JSON payload |
githubAdapter({...}) |
GitHub Issues | Embedded in body |
slackAdapter({...}) |
Slack channel | Block Kit message |
telegramAdapter({...}) |
Telegram chat | Photo with caption |
Custom adapters implement { name: string, send(event): Promise<{ ok, error? }> }.
// app/api/feedback/route.ts
import { createFeedbackHandler } from "@microsoft/snapfeed-server/nextjs";
import { slackAdapter } from "@microsoft/snapfeed/adapters";
const handler = createFeedbackHandler({
adapters: [slackAdapter({ webhookUrl: process.env.SLACK_WEBHOOK! })],
rateLimit: { max: 10, windowMs: 60_000 },
allowedOrigins: ["https://myapp.com"],
});
export const POST = handler.POST;
export const GET = handler.GET;import express from "express";
import { createExpressRouter } from "@microsoft/snapfeed-server/express";
import { openDb } from "@microsoft/snapfeed-server";
const app = express();
app.use(express.json());
app.use(createExpressRouter(openDb({ path: "./feedback.db" })));
app.listen(3000);The standalone Hono server includes rate limiting by default (60 req/min). For custom setups, use the security middleware individually:
import { snapfeedRoutes, openDb } from "@microsoft/snapfeed-server";
import {
rateLimit,
originAllowlist,
payloadLimits,
} from "@microsoft/snapfeed-server/security";
import { Hono } from "hono";
const app = new Hono();
// Rate limit: 30 requests per minute per IP
app.use("/api/*", rateLimit({ max: 30, windowMs: 60_000 }));
// Only accept requests from your domain
app.use("/api/*", originAllowlist({ origins: ["https://myapp.com"] }));
// Limit payload sizes (10KB text, 5MB screenshots)
app.use(
"/api/*",
payloadLimits({ maxPayloadBytes: 10_000, maxScreenshotBytes: 5_242_880 }),
);
app.route("/", snapfeedRoutes(openDb({ path: "./feedback.db" })));MIT