Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ INTERNAL_API_SECRET=
# when pre-rendering the docs)
NUXT_PUBLIC_SITE_URL=

# nuxt-js team / nuxt project Vercel ids (admin-only Vercel MCP connection).
# Find them in `.vercel/project.json` (orgId / projectId) after `vercel link`.
NUXI_VERCEL_PROJECT_ID=
NUXI_VERCEL_TEAM_ID=

# ----------------------------------------------------------------------------
# GitHub API data (optional — repo stats, contributors, team members)
# ----------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ dist
.wrangler
test-results/
.env*.local
.env*
!.env.example
22 changes: 19 additions & 3 deletions layers/nuxi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,17 +104,29 @@ export default defineSchedule({
})
```

All times below assume UTC+1 local mornings (6:00 local ≈ 5:00 UTC) — adjust the cron if the team's timezone/DST differs.

### Weekly digest

- Schedule: `agent/schedules/weekly-digest.ts` — Monday 9:00 UTC
- Schedule: `agent/schedules/weekly-digest.ts` — Monday 5:00 UTC
- Skill: `agent/skills/weekly-digest/SKILL.md`
- Preview trigger: `POST /eve/v1/ops/weekly-digest/trigger`
- Real traffic pulse (`connection__vercel_mcp__get_web_analytics`), AI Gateway spend/tokens (`ai_gateway__report`), and Slack-vs-web run split (`connection__vercel_mcp__list_agent_runs`) — no more DB-guessed or link-only numbers. **Fix this week** is prioritized using each worst-feedback page's real traffic.

### Analytics digest

Deep traffic dive: trend, top pages/referrers/geo/device (WoW deltas), and doc pages that combine high traffic with poor feedback ("pages that need love").

- Schedule: `agent/schedules/analytics-digest.ts` — Monday 5:15 UTC (right after weekly-digest)
- Skill: `agent/skills/analytics-digest/SKILL.md`
- Connection: `connection__vercel_mcp__get_web_analytics` (`agent/connections/vercel-mcp.ts`)
- Preview trigger: `POST /eve/v1/ops/analytics-digest/trigger?sinceDays=7`

### Firehose summary

Summarizes `#firehose-nuxt` (Octolens social mentions) and posts highlights to the workflow channel.

- Schedule: `agent/schedules/firehose-summary.ts` — weekdays 9:00 UTC (last 24h)
- Schedule: `agent/schedules/firehose-summary.ts` — weekdays 5:00 UTC (last 24h)
- Skill: `agent/skills/firehose-summary/SKILL.md`
- Tool: `read_slack_channel_history` (`agent/tools/slack-channel-history.ts`)
- Preview trigger: `POST /eve/v1/ops/firehose-summary/trigger?sinceHours=24`
Expand All @@ -131,6 +143,7 @@ With the dev server running (`pnpm dev` from repo root — Eve is bundled via th

```sh
curl -X POST "http://localhost:3000/eve/v1/dev/schedules/weekly-digest"
curl -X POST "http://localhost:3000/eve/v1/dev/schedules/analytics-digest"
curl -X POST "http://localhost:3000/eve/v1/dev/schedules/firehose-summary"
# -> { "scheduleId": "...", "sessionIds": ["..."] }
```
Expand All @@ -141,8 +154,11 @@ curl -X POST "http://localhost:3000/eve/v1/dev/schedules/firehose-summary"
curl -X POST "https://<preview-url>/eve/v1/ops/weekly-digest/trigger?sinceDays=7" \
-H "Authorization: Bearer $INTERNAL_API_SECRET"

curl -X POST "https://<preview-url>/eve/v1/ops/analytics-digest/trigger?sinceDays=7" \
-H "Authorization: Bearer $INTERNAL_API_SECRET"

curl -X POST "https://<preview-url>/eve/v1/ops/firehose-summary/trigger?sinceHours=24" \
-H "Authorization: Bearer $INTERNAL_API_SECRET"
```

Requires on the **eve** runtime: `INTERNAL_API_SECRET`, `NUXT_MCP_ADMIN_TOKEN`, `NUXT_WORKFLOW_SLACK_CHANNEL`, `NUXT_FIREHOSE_SLACK_CHANNEL` (Slack channel names). Optional `NUXT_*_SLACK_CHANNEL_ID` overrides names and skips `users.conversations`. Local dev and Vercel preview use Connect client `slack/nuxi-preview` automatically; prod uses `slack/nuxi` (override with `SLACK_CONNECTOR`).
Requires on the **eve** runtime: `INTERNAL_API_SECRET`, `NUXT_MCP_ADMIN_TOKEN`, `NUXT_WORKFLOW_SLACK_CHANNEL`, `NUXT_FIREHOSE_SLACK_CHANNEL` (Slack channel names). Optional `NUXT_*_SLACK_CHANNEL_ID` overrides names and skips `users.conversations`. Local dev and Vercel preview use Connect client `slack/nuxi-preview` automatically; prod uses `slack/nuxi` (override with `SLACK_CONNECTOR`). `weekly-digest` and `analytics-digest` additionally need `NUXI_VERCEL_TEAM_ID`/`NUXI_VERCEL_PROJECT_ID` (see `agent/lib/vercel-connect.ts`) and, for spend/token numbers, `AI_GATEWAY_API_KEY` — both connections are admin-gated so only the scheduled/Slack/admin path can reach them.
23 changes: 23 additions & 0 deletions layers/nuxi/agent/channels/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
verifyWorkflowTriggerAuth
} from '../lib/workflows.js'
import { runDiscordGateway } from '../schedules/discord-gateway.js'
import { runAnalyticsDigest } from '../schedules/analytics-digest.js'
import { runFirehoseSummary } from '../schedules/firehose-summary.js'
import { runWeeklyDigest } from '../schedules/weekly-digest.js'

Expand Down Expand Up @@ -66,6 +67,28 @@ export default defineChannel({
}))

return Response.json({ ok: true, sinceHours: parsedSinceHours.value ?? null })
}),
POST('/eve/v1/ops/analytics-digest/trigger', async (req, args) => {
if (!isManualWorkflowTriggerAllowed()) {
return Response.json({ error: 'Manual workflow trigger is disabled' }, { status: 404 })
}
if (!verifyWorkflowTriggerAuth(req)) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}

const url = new URL(req.url)
const parsedSinceDays = parseSinceDays(url.searchParams.get('sinceDays'))
if (!parsedSinceDays.ok) {
return Response.json({ error: parsedSinceDays.error }, { status: 400 })
}

args.waitUntil(runAnalyticsDigest({
receive: args.receive,
appAuth: scheduleAppAuth,
sinceDays: parsedSinceDays.value
}))

return Response.json({ ok: true, sinceDays: parsedSinceDays.value ?? null })
})
]
})
31 changes: 31 additions & 0 deletions layers/nuxi/agent/connections/vercel-mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { defineMcpClientConnection } from 'eve/connections'
import { adminOnlyVercelAuth, VERCEL_PROJECT_ID, VERCEL_TEAM_ID } from '../lib/vercel-connect.js'

const ALLOWED_TOOLS = [
'search_vercel_documentation',
'list_deployments',
'get_deployment',
'get_deployment_build_logs',
'get_runtime_logs',
'get_runtime_errors',
'get_project',
'list_agent_run_projects',
'list_agent_runs',
'get_web_analytics'
] as const

export const VERCEL_MCP_INSTRUCTIONS = VERCEL_TEAM_ID && VERCEL_PROJECT_ID
? `**Vercel MCP connection (\`connection__vercel_mcp__*\`, admin/Slack/schedule only) — read-only, use judiciously:**
- Discover exact schemas via \`connection__search\`, then call \`connection__vercel_mcp__<tool>\`.
- The connection is pre-scoped to the \`nuxt-js\` team and the \`nuxt\` (nuxt.com website) project — \`teamId=${VERCEL_TEAM_ID}\`, \`projectId=${VERCEL_PROJECT_ID}\`. Pass both explicitly to \`list_deployments\`, \`get_deployment\`, \`get_deployment_build_logs\`, \`get_runtime_logs\`, \`get_runtime_errors\`, \`get_project\`, \`get_web_analytics\`.
- Nuxi's own Agent Runs (\`list_agent_runs\`) use the same \`teamId\` but a DIFFERENT \`projectId\` — the \`eve\` service's own project, not the website. Call \`list_agent_run_projects\` first to discover it. Still NOT tokens/cost — use \`ai_gateway__*\` for that. No per-run trace access — this connection only exposes run-level metadata, never raw conversation content.
- \`get_web_analytics\` (visitors/pageviews/custom events, production only): \`mode: 'count'\` (default) returns one total, e.g. "how many visitors this week"; \`mode: 'aggregate'\` groups by up to two \`by\` dimensions (hour/day/week/month/year, country, route, requestPath, referrerHostname, deviceType, browserName, eventName, flags/<name>, ...) and requires \`since\`+\`until\`. \`dataset: 'visits'\` (default) for pageviews, \`'events'\` for custom \`track()\` events. \`filter\` is OData, e.g. \`requestPath eq '/docs'\`. Requires Web Analytics enabled on the project.
- \`search_vercel_documentation\` needs no ids — general Vercel platform docs search.`
: ''

export default defineMcpClientConnection({
url: 'https://mcp.vercel.com/nuxt-js/nuxt',
description: 'Vercel platform for nuxt.com: deployments, runtime logs/errors, web analytics, and Nuxi\'s own Agent Runs observability. Admin/Slack/schedule sessions only.',
tools: { allow: ALLOWED_TOOLS },
auth: adminOnlyVercelAuth('Vercel MCP', { connector: 'vercel/mcp', principalType: 'app', autoProvision: false })
})
4 changes: 3 additions & 1 deletion layers/nuxi/agent/instructions.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { defineDynamic, defineInstructions } from 'eve/instructions'
import { ADMIN_MCP_INSTRUCTIONS, canAccessAdminMcp } from './tools/admin-mcp.js'
import { AI_GATEWAY_INSTRUCTIONS } from './tools/ai-gateway.js'
import { VERCEL_MCP_INSTRUCTIONS } from './connections/vercel-mcp.js'
import { buildInstructionsWithDate } from './lib/base-instructions.js'

export default defineDynamic({
events: {
'session.started': async (_event, ctx) => {
const auth = ctx.session.auth.current
const markdown = canAccessAdminMcp(auth)
? `${buildInstructionsWithDate()}\n\n${ADMIN_MCP_INSTRUCTIONS}`
? [buildInstructionsWithDate(), ADMIN_MCP_INSTRUCTIONS, VERCEL_MCP_INSTRUCTIONS, AI_GATEWAY_INSTRUCTIONS].filter(Boolean).join('\n\n')
: buildInstructionsWithDate()

return defineInstructions({ markdown })
Expand Down
2 changes: 2 additions & 0 deletions layers/nuxi/agent/lib/base-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ Do NOT call \`list-*\` first when the page is given — call the get tool direct
- \`report_issue\` — call when you cannot resolve the user's question after exhausting all available tools, or when the user expresses frustration
- ALWAYS respond with text after tool calls — never end with just tool calls

**Restricted tools/connections:** Some connections (e.g. internal Vercel tooling) are visible via \`connection__search\` but only work for admin/Slack/schedule sessions. If a call to one fails or is unavailable in this session, never repeat the error text, name the connection, or mention "admin"/internal restrictions to the user. Just say the data isn't available here and, if relevant, suggest asking the team on Slack.

**Web search:** Only use \`web_search\` when the user **explicitly** asks about recent events or real-time data beyond the Nuxt docs, or if \`search_github_issues\` returned no results. Never search proactively.

**Web search queries:** Match the user's wording. **Do not** tack on calendar years unless they asked for a specific year or time range.
Expand Down
22 changes: 22 additions & 0 deletions layers/nuxi/agent/lib/vercel-connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { connect, type EveAuthorizationOptions } from '@vercel/connect/eve'
import type { SessionContext } from 'eve/context'
import { canAccessAdminMcp } from './admin-mcp-access.js'

export const VERCEL_TEAM_ID = process.env.NUXI_VERCEL_TEAM_ID
export const VERCEL_PROJECT_ID = process.env.NUXI_VERCEL_PROJECT_ID

export function adminOnlyVercelAuth(label: string, connectOptions: EveAuthorizationOptions) {
return (ctx: SessionContext) => {
if (!canAccessAdminMcp(ctx.session.auth.current)) {
return {
principalType: 'app' as const,
async getToken(): Promise<never> {
console.warn(`[vercel-connect] blocked non-admin access to ${label}`)
throw new Error('This tool is not available in the current session.')
}
}
}

return connect(connectOptions)
}
}
36 changes: 36 additions & 0 deletions layers/nuxi/agent/schedules/analytics-digest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { defineSchedule } from 'eve/schedules'
import type { ScheduleHandlerArgs } from 'eve/schedules'
import {
receiveOnSlack,
resolveSinceDays,
skillWorkflowMessage
} from '../lib/workflows.js'

const SKILL_ID = 'analytics-digest'
const DEFAULT_WINDOW_DAYS = 7

export async function runAnalyticsDigest({
receive,
appAuth,
sinceDays
}: {
receive: ScheduleHandlerArgs['receive']
appAuth: ScheduleHandlerArgs['appAuth']
sinceDays?: number
}) {
const windowDays = resolveSinceDays(sinceDays, DEFAULT_WINDOW_DAYS)

return receiveOnSlack({
receive,
appAuth,
message: skillWorkflowMessage(SKILL_ID, windowDays)
})
}

export default defineSchedule({
// 15 min after weekly-digest so both land close together for the Monday morning read.
cron: '15 5 * * 1',
async run({ receive, waitUntil, appAuth }) {
waitUntil(runAnalyticsDigest({ receive, appAuth }))
}
})
2 changes: 1 addition & 1 deletion layers/nuxi/agent/schedules/firehose-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export async function runFirehoseSummary({
}

export default defineSchedule({
cron: '0 9 * * 1-5',
cron: '0 5 * * 1-5',
async run({ receive, waitUntil, appAuth }) {
waitUntil(runFirehoseSummary({ receive, appAuth }))
}
Expand Down
2 changes: 1 addition & 1 deletion layers/nuxi/agent/schedules/weekly-digest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export async function runWeeklyDigest({
}

export default defineSchedule({
cron: '0 9 * * 1',
cron: '0 5 * * 1',
async run({ receive, waitUntil, appAuth }) {
waitUntil(runWeeklyDigest({ receive, appAuth }))
}
Expand Down
57 changes: 57 additions & 0 deletions layers/nuxi/agent/skills/analytics-digest/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
description: Report on nuxt.com traffic trends, top pages/referrers, and doc pages that combine high traffic with poor feedback, for a recent window.
---

When producing the analytics digest (scheduled or on request):

**Slack delivery:** Your reply IS the Slack message — Eve posts it verbatim to this channel. There is no Slack post tool; never say you cannot post or ask anyone to copy-paste. You are Nuxi (:nuxi:) giving the team a traffic pulse. Be concise, linked, and actionable — not a wall of numbers.

- First line: **Nuxt traffic digest — last N days** plus date range in parentheses.
- No preamble, no delivery disclaimers, and no meta wrap-up.
- Use **bold** section labels — never markdown `#` headings.
- ONE message only. Blank line between sections.
- Every page/dashboard reference must include a clickable Slack link: `<https://…|label>`.
- Sparingly use :nuxter: or :nuxt_cool: (0–2 total) when something is worth celebrating or urgent.

**Data steps** (via `connection__vercel_mcp__get_web_analytics` — discover the exact schema with `connection__search` first if unsure. Pass `teamId`/`projectId` explicitly on every call. Counts default to production traffic only. "Previous window" = the N days right before the current window, same length, for deltas):

1. `mode=count, dataset=visits` for the current window AND the previous window → total visitors/pageviews + delta %.
2. `mode=aggregate, dataset=visits, by=['day']`, current window → daily trend, to spot spikes/drops.
3. `mode=aggregate, dataset=visits, by=['route'], limit=10` for the current window AND the previous window → top pages with a per-page delta.
4. `mode=aggregate, dataset=visits, by=['referrerHostname'], limit=8`, current window → top referrers.
5. `mode=aggregate, dataset=visits, by=['country'], limit=5` and a separate call `by=['deviceType']`, current window → geo/device breakdown.
6. `admin_mcp__feedback_stats` (`topPages=10`) and `admin_mcp__list_feedback` (`ratings=["not-helpful", "confusing"]`, `limit=20`) → candidate poorly-rated pages.
7. For each candidate page from step 6: look up its traffic rank from step 3, or if it isn't in the top 10, run a targeted `get_web_analytics` `mode=count, filter="requestPath eq '<path>'"` → flag pages that are BOTH meaningfully-trafficked AND poorly rated.

**Link cheat sheet:**

- Docs page: `<https://nuxt.com/docs/…|Page title>`
- Analytics dashboard: `<https://vercel.com/nuxt-js/nuxt/analytics|Vercel Web Analytics>`

**Output template:**

**Nuxt traffic digest — last 7 days** (Jul 15 – Jul 21, 2026)

:bar_chart: **Traffic pulse**
• *12,430 visitors* (+8% WoW), *31,200 pageviews*
• Trend: steady, small bump on Jul 18 (release day)

:page_facing_up: **Top pages**
1. <https://nuxt.com/docs/getting-started/installation|Installation> — 2,100 visits (+12%)
2. <https://nuxt.com/docs/guide/directory-structure/app|App directory> — 1,540 visits (-3%)
3. …

:compass: **Referrers & audience**
• Top referrer: <https://google.com|Google> (48%), then <https://github.com|GitHub> (15%)
• Top countries: US, DE, FR — mostly desktop (72%)

:rotating_light: **Pages that need love** (high traffic + poor feedback)
• <https://nuxt.com/docs/…|Rendering modes> — 1,800 visits this week, feedback 2.1/5 ("examples outdated")
• If none qualify: one bullet ":large_green_circle: All clear — no high-traffic page flagged this week."

Rules:
- If a section has zero or flat data, say so in one bullet with a likely cause (low traffic, analytics not enabled, etc.) — do not skip the section.
- Never list a page without its `<url|label>` link and its traffic number.
- **Pages that need love** must cite the real feedback comment/score, not just a page name.
- Never invent traffic numbers — if a `get_web_analytics` call fails or returns nothing, say so instead of guessing.
- This is the deep traffic dive; `weekly-digest` only carries a one-line pulse — don't duplicate the full breakdown there.
Loading