Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 12 additions & 1 deletion .kilo/skills/kilo-mcp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,22 @@ The catalog is `services/kilo-mcp/catalog.json`. This skill is generated from
it, and the catalog wins when the two disagree — read the catalog, not this
file, for current detail.

## Reporting feedback and bugs

Report bugs, confusing behavior, and feature requests about the Kilo platform
through `feedback.submit` — the HTTP `POST /feedback` endpoint does the same
thing. Batch everything you want to report into one submission instead of
sending several: the endpoint accepts one submission per authenticated user per
minute, and a second call within a minute is refused with a message asking you
to batch. MCP error responses also point agents here, so treat this as the place
to send platform feedback and bug reports rather than a support channel.

## Areas

The first path segment names an area. Search to learn an area's vocabulary:
each row's `summary` and `tags` are what the search matches.

**862 procedures** — **368 queries**, **494 mutations** — under **51 prefixes**.
**863 procedures** — **368 queries**, **495 mutations** — under **52 prefixes**.

| Prefix | Procedures | Queries | Mutations |
|---|---:|---:|---:|
Expand Down Expand Up @@ -90,6 +100,7 @@ each row's `summary` and `tags` are what the search matches.
| `appBuilderFeedback` | 1 | 0 | 1 |
| `appReportedMessages` | 1 | 0 | 1 |
| `cloudAgentNextFeedback` | 1 | 0 | 1 |
| `feedback` | 1 | 0 | 1 |
| `kiloChat` | 1 | 1 | 0 |
| `models` | 1 | 1 | 0 |
| `platformIntegrations` | 1 | 1 | 0 |
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/app/feedback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import 'server-only';

import { NextResponse, type NextRequest } from 'next/server';
import * as z from 'zod';
import { FeedbackFor, FeedbackSource } from '@/lib/feedback/enums';
import { isFeedbackRateLimitError, submitUserFeedback } from '@/lib/feedback/submit';
import { getUserFromAuth } from '@/lib/user/server';

/**
* HTTP twin of the `feedback.submit` tRPC mutation. An authenticated agent
* posts feedback or a bug report here; the submission lands in the same
* `user_feedback` store and shares the same per-user limit. Unauthenticated
* callers get 401, a caller inside the rolling window gets 429 with the
* batch-into-one-submission message.
*/
const SubmitFeedbackRequestSchema = z.object({
feedback_text: z.string().optional().default(''),
feedback_for: z.string().min(1).default(FeedbackFor.Unknown),
feedback_batch: z.string().min(1).optional(),
source: z.string().min(1).default(FeedbackSource.Web),
context_json: z.record(z.string(), z.unknown()).default({}),
});

export async function POST(request: NextRequest): Promise<NextResponse> {
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
if (!user) return authFailedResponse;

let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}

const parsed = SubmitFeedbackRequestSchema.safeParse(rawBody);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid feedback submission' }, { status: 400 });
}

try {
const { id } = await submitUserFeedback({
userId: user.id,
feedbackText: parsed.data.feedback_text,
feedbackFor: parsed.data.feedback_for,
feedbackBatch: parsed.data.feedback_batch,
source: parsed.data.source,
contextJson: parsed.data.context_json,
});
return NextResponse.json({ id }, { status: 200 });
} catch (error) {
if (isFeedbackRateLimitError(error)) {
return NextResponse.json(
{ error: error.message },
{
status: 429,
headers: { 'Retry-After': String(Math.ceil(error.retryAfterMs / 1000)) },
}
);
}
throw error;
}
}
160 changes: 160 additions & 0 deletions apps/web/src/lib/feedback/submit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import 'server-only';

import { desc, eq } from 'drizzle-orm';
import { db } from '@/lib/drizzle';
import { user_feedback } from '@kilocode/db/schema';
import { FeedbackFor, FeedbackSource } from '@/lib/feedback/enums';
import { SLACK_USER_FEEDBACK_WEBHOOK_URL } from '@/lib/config.server';

/**
* Shared feedback submission service.
*
* Both the `userFeedback.create` tRPC mutation (published in the MCP catalog and
* reachable via `kilo_call`) and the HTTP `/feedback` endpoint funnel through
* here so an authenticated agent gets the same storage and the same limit.
*
* Authentication is the caller's responsibility: it resolves the authenticated
* user and passes the user id in. This module never reads auth headers itself.
*
* Storage: submissions are written to the existing `user_feedback` PostgreSQL
* table in `apps/web`. `apps/web` has no Cloudflare D1 or R2 binding, so the
* "d2" store maps to this table, which is where the repo already keeps
* user feedback.
*/

/** Rolling window in which a single user may submit at most one piece of feedback. */
export const FEEDBACK_RATE_LIMIT_WINDOW_MS = 60_000;

/**
* Message returned to a limited caller. It states the limit and asks the caller
* to batch, so an agent can self-correct instead of retrying blindly.
*/
export const FEEDBACK_RATE_LIMIT_MESSAGE =
'Feedback is limited to 1 submission per minute per user. Please batch your feedback into one submission and try again.';

/**
* Thrown when a user already submitted feedback within the rolling window.
* Callers map this to their transport error shape (tRPC `TOO_MANY_REQUESTS`,
* HTTP 429) while preserving {@link FeedbackRateLimitError.message}.
*/
export class FeedbackRateLimitError extends Error {
/** Milliseconds until the next submission is allowed. */
readonly retryAfterMs: number;

constructor(retryAfterMs: number) {
super(FEEDBACK_RATE_LIMIT_MESSAGE);
this.name = 'FeedbackRateLimitError';
this.retryAfterMs = retryAfterMs;
}
}

export function isFeedbackRateLimitError(error: unknown): error is FeedbackRateLimitError {
return error instanceof FeedbackRateLimitError;
}

export type SubmitUserFeedbackInput = {
/** Resolved authenticated user id. */
userId: string;
feedbackText?: string;
feedbackFor?: string;
feedbackBatch?: string;
source?: string;
contextJson?: Record<string, unknown>;
};

export type SubmitUserFeedbackResult = {
id: string;
};

/**
* Parse the PostgreSQL `timestamptz` text Drizzle returns with `mode: 'string'`
* (for example `2026-04-29 01:16:12.945+00`). Returns null for unparseable text
* so a malformed legacy row cannot block all future submissions.
*/
function parseCreatedAt(value: string): number | null {
const parsed = new Date(value).getTime();
return Number.isNaN(parsed) ? null : parsed;
}

/**
* Enforce the per-user rolling limit by reading the user's most recent
* `user_feedback` row. `IDX_user_feedback_kilo_user_id` backs the lookup and
* `IDX_user_feedback_created_at` backs the ordering.
*/
async function enforceFeedbackRateLimit(userId: string, now: number): Promise<void> {
const [mostRecent] = await db
.select({ created_at: user_feedback.created_at })
.from(user_feedback)
.where(eq(user_feedback.kilo_user_id, userId))
.orderBy(desc(user_feedback.created_at))
.limit(1);

if (!mostRecent) return;

const submittedAt = parseCreatedAt(mostRecent.created_at);
if (submittedAt === null) return;

const elapsedMs = now - submittedAt;
if (elapsedMs < FEEDBACK_RATE_LIMIT_WINDOW_MS) {
throw new FeedbackRateLimitError(FEEDBACK_RATE_LIMIT_WINDOW_MS - Math.max(elapsedMs, 0));
}
}

/**
* Best-effort notification to the Kilo Slack workspace. Uses an Incoming Webhook
* URL so it is not coupled to any user/org Slack installation, and never blocks
* or fails the submission.
*/
function notifySlack(input: SubmitUserFeedbackInput): void {
if (!SLACK_USER_FEEDBACK_WEBHOOK_URL) return;

const textLines = [
'*New user feedback:* :old_man_yells_at_kilo:',
`• user: \`${input.userId}\``,
`• for: \`${input.feedbackFor ?? FeedbackFor.Unknown}\``,
`• source: \`${input.source ?? FeedbackSource.Unknown}\``,
input.feedbackBatch ? `• batch: \`${input.feedbackBatch}\`` : null,
'',
'• raw feedback:',
'```',
input.feedbackText?.trim() ? input.feedbackText.trim() : '_<empty>_',
'```',
].filter((line): line is string => !!line);

fetch(SLACK_USER_FEEDBACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: textLines.join('\n') }),
}).catch(error => {
console.error('[UserFeedback] Failed to post to Slack webhook', error);
});
}

/**
* Persist one feedback submission for an authenticated user.
*
* Throws {@link FeedbackRateLimitError} when the user submitted feedback within
* the last {@link FEEDBACK_RATE_LIMIT_WINDOW_MS}.
*/
export async function submitUserFeedback(
input: SubmitUserFeedbackInput
): Promise<SubmitUserFeedbackResult> {
const now = Date.now();
await enforceFeedbackRateLimit(input.userId, now);
Comment thread
iscekic marked this conversation as resolved.
Outdated

const [inserted] = await db
.insert(user_feedback)
.values({
kilo_user_id: input.userId,
feedback_text: input.feedbackText ?? '',
feedback_for: input.feedbackFor ?? FeedbackFor.Unknown,
feedback_batch: input.feedbackBatch,
source: input.source ?? FeedbackSource.Unknown,
context_json: input.contextJson ?? {},
})
.returning({ id: user_feedback.id });

notifySlack(input);

return { id: inserted.id };
}
42 changes: 42 additions & 0 deletions apps/web/src/routers/feedback-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import 'server-only';

import { TRPCError } from '@trpc/server';
import * as z from 'zod';
import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init';
import { FeedbackFor, FeedbackSource } from '@/lib/feedback/enums';
import { isFeedbackRateLimitError, submitUserFeedback } from '@/lib/feedback/submit';

/**
* MCP-facing feedback entry point. `feedback.submit` is the tRPC twin of the
* HTTP `POST /feedback` route: both authenticate, both funnel into the shared
* submission service, and both surface the same rate-limit message. An
* unauthenticated tRPC caller is refused by `createTRPCContext`, which throws
* `UNAUTHORIZED` before this procedure runs.
*/
const SubmitFeedbackInputSchema = z.object({
feedback_text: z.string().optional().default(''),
feedback_for: z.string().min(1).default(FeedbackFor.Unknown),
feedback_batch: z.string().min(1).optional(),
source: z.string().min(1).default(FeedbackSource.Web),
context_json: z.record(z.string(), z.unknown()).default({}),
});

export const feedbackRouter = createTRPCRouter({
submit: baseProcedure.input(SubmitFeedbackInputSchema).mutation(async ({ ctx, input }) => {
try {
return await submitUserFeedback({
userId: ctx.user.id,
feedbackText: input.feedback_text,
feedbackFor: input.feedback_for,
feedbackBatch: input.feedback_batch,
source: input.source,
contextJson: input.context_json,
});
} catch (error) {
if (isFeedbackRateLimitError(error)) {
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: error.message });
}
throw error;
}
}),
});
2 changes: 2 additions & 0 deletions apps/web/src/routers/root-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { kiloPassRouter } from '@/routers/kilo-pass-router';
import { agentProfilesRouter } from '@/routers/agent-profiles-router';
import { webhookTriggersRouter } from '@/routers/webhook-triggers-router';
import { userFeedbackRouter } from '@/routers/user-feedback-router';
import { feedbackRouter } from '@/routers/feedback-router';
import { appBuilderFeedbackRouter } from '@/routers/app-builder-feedback-router';
import { cloudAgentNextFeedbackRouter } from '@/routers/cloud-agent-next-feedback-router';
import { kiloChatRouter } from '@/routers/kilo-chat-router';
Expand Down Expand Up @@ -89,6 +90,7 @@ export const rootRouter = createTRPCRouter({
agentProfiles: agentProfilesRouter,
webhookTriggers: webhookTriggersRouter,
userFeedback: userFeedbackRouter,
feedback: feedbackRouter,
appBuilderFeedback: appBuilderFeedbackRouter,
cloudAgentNextFeedback: cloudAgentNextFeedbackRouter,
kiloChat: kiloChatRouter,
Expand Down
52 changes: 14 additions & 38 deletions apps/web/src/routers/user-feedback-router.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import 'server-only';

import { TRPCError } from '@trpc/server';
import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init';
import { db } from '@/lib/drizzle';
import { FeedbackFor, FeedbackSource } from '@/lib/feedback/enums';
import { user_feedback } from '@kilocode/db/schema';
import { isFeedbackRateLimitError, submitUserFeedback } from '@/lib/feedback/submit';
import * as z from 'zod';
import { SLACK_USER_FEEDBACK_WEBHOOK_URL } from '@/lib/config.server';

const CreateUserFeedbackInputSchema = z.object({
feedback_text: z.string().optional().default(''),
Expand All @@ -17,43 +16,20 @@ const CreateUserFeedbackInputSchema = z.object({

export const userFeedbackRouter = createTRPCRouter({
create: baseProcedure.input(CreateUserFeedbackInputSchema).mutation(async ({ ctx, input }) => {
const [inserted] = await db
.insert(user_feedback)
.values({
kilo_user_id: ctx.user.id,
feedback_text: input.feedback_text,
feedback_for: input.feedback_for,
feedback_batch: input.feedback_batch,
try {
return await submitUserFeedback({
userId: ctx.user.id,
feedbackText: input.feedback_text,
feedbackFor: input.feedback_for,
feedbackBatch: input.feedback_batch,
source: input.source,
context_json: input.context_json,
})
.returning({ id: user_feedback.id });

// Best-effort notification to the Kilo Slack workspace.
// This uses an Incoming Webhook URL so it is not coupled to any user/org Slack installation.
if (SLACK_USER_FEEDBACK_WEBHOOK_URL) {
const textLines = [
'*New user feedback:* :old_man_yells_at_kilo:',
`• user: \`${ctx.user.id}\``,
`• for: \`${input.feedback_for}\``,
`• source: \`${input.source}\``,
input.feedback_batch ? `• batch: \`${input.feedback_batch}\`` : null,
'',
`• raw feedback:`,
'```',
input.feedback_text?.trim() ? input.feedback_text.trim() : '_<empty>_',
'```',
].filter((line): line is string => !!line);

fetch(SLACK_USER_FEEDBACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: textLines.join('\n') }),
}).catch(error => {
console.error('[UserFeedback] Failed to post to Slack webhook', error);
contextJson: input.context_json,
});
} catch (error) {
if (isFeedbackRateLimitError(error)) {
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: error.message });
}
throw error;
}

return inserted;
}),
});
Loading
Loading