Skip to content

Developers

Everything you need to add Luchy to an app. The package is luchy on npm and the source is on GitHub.

1. Add the script

Any site, any framework. Pageviews start flowing immediately.

index.html
<script
  src="https://cdn.luchy.app/luchy.min.js"
  data-api-key="YOUR_INGEST_KEY"
  data-auto-pageviews
  defer
></script>

2. Install the package

Browser, server and React Router entry points ship in one package.

terminal
bun add luchy

3. React Router middleware

Return the signed-in user, their tenant and, when someone is impersonating, the real actor. Return undefined for anonymous traffic. Identity is signed once per request and shared with the browser script, so both land in one session.

app/root.tsx
// app/root.tsx
import { env } from 'cloudflare:workers';
import { luchyMiddleware, getLuchy, LuchyScript } from 'luchy/react-router';

export const middleware = [
  luchyMiddleware({
    apiKey: env.LUCHY_API_KEY,
    secret: env.LUCHY_SECRET_KEY,
    waitUntil: ({ context }) => (p) => context.cloudflare.ctx.waitUntil(p),
    identity: async ({ request }) => {
      const session = await getSession(request);
      if (!session) return undefined;

      return {
        user: session.user.id,
        tenant: String(session.workspaceId),
        actor: session.impersonatorId,
        props: { role: session.user.role },
        traits: { name: session.user.name, email: session.user.email }
      };
    }
  })
];

export const loader = ({ context }: Route.LoaderArgs) => {
  return { luchy: getLuchy(context) };
};

// in <Layout>, next to <Scripts />
<LuchyScript {...loaderData.luchy} />

The middleware also turns every mutation into a named event, after the route and intent. IDs collapse to :id, reads are skipped, redirects still count, and events ship through waitUntil so responses never wait.

How requests become events
RequestEvent
POST /settings/team
intent=invite
settings/team:invite
POST /invoices/1042/send
303 redirect
invoices/:id/send
DELETE /projects/42
intent=archive
projects/:id:archive
GET /reports
read
skipped, a read

4. Any other server

Send events from any server, worker or queue consumer with luchy/server. Same signed identity, same sessions.

worker.ts
import { createLuchy } from 'luchy/server';

const luchy = createLuchy({
  apiKey: env.LUCHY_API_KEY,
  secret: env.LUCHY_SECRET_KEY
});

await luchy.track(
  { name: 'export:csv', pathname: '/reports', payload: { rows: 1200 } },
  { user: 'u_1042', tenant: 'acme' }
);

Read-only SQL

Your events and sessions live in plain tables. Query them directly with read-only SQL.

read-only SQL
-- actions per user per tenant, last 30 days
select s.user, e.tenant, count(*) as actions
from events e
join sessions s on s.id = e.session_id
where e.type = 'event'
  and e.created_at > datetime('now', '-30 days')
group by s.user, e.tenant
order by actions desc
limit 20;

Ask from Claude (MCP)

Luchy is an MCP server at https://dash.luchy.app/mcp. Mint an API key in the dashboard under Keys and send it as Authorization: Bearer wak_…. Ask “who used my app this week vs last?” and the answer comes back with numbers and, in hosts that support MCP Apps, a chart.

claude_desktop_config.json
{
  "mcpServers": {
    "luchy": {
      "command": "npx",
      "args": [
        "mcp-remote",
        "https://dash.luchy.app/mcp",
        "--header",
        "Authorization:Bearer ${LUCHY_KEY}"
      ],
      "env": { "LUCHY_KEY": "wak_…" }
    }
  }
}

The same methods are a plain HTTP API: POST /api/analytics.query, documented at /llms.txt and /openapi.json.