diff --git a/packages/backend/export.json b/packages/backend/export.json
new file mode 100644
index 0000000..728fa9c
--- /dev/null
+++ b/packages/backend/export.json
@@ -0,0 +1,452 @@
+{
+ "organisation": {
+ "id": 5,
+ "name": "OB",
+ "description": "For personal use",
+ "slug": "ob",
+ "iconURL": "https://images.sprintpm.org/org-icons/cda1e49c-486a-4a40-ace6-d63eaba79cd9.png",
+ "statuses": {
+ "TO DO": "#fafafa",
+ "IN PROGRESS": "#f97316",
+ "REVIEW": "#8952bc",
+ "DONE": "#22c55e",
+ "REJECTED": "#ef4444",
+ "ARCHIVED": "#a1a1a1",
+ "MERGED": "#a1a1a1"
+ },
+ "issueTypes": {
+ "Task": {
+ "icon": "checkBox",
+ "color": "#e4bd47"
+ },
+ "Bug": {
+ "icon": "bug",
+ "color": "#ef4444"
+ },
+ "Ideation": {
+ "icon": "edit",
+ "color": "#c955c5"
+ },
+ "Quality of Life": {
+ "icon": "moon",
+ "color": "#656ad3"
+ },
+ "Performance": {
+ "icon": "loader",
+ "color": "#5ed36c"
+ }
+ },
+ "features": {
+ "userAvatars": true,
+ "issueTypes": true,
+ "issueStatus": true,
+ "issueDescriptions": true,
+ "issueTimeTracking": true,
+ "issueAssignees": true,
+ "issueAssigneesShownInTable": true,
+ "issueCreator": true,
+ "issueComments": true,
+ "sprints": true
+ },
+ "createdAt": "2026-01-29T19:24:07.015Z",
+ "updatedAt": "2026-01-29T19:24:07.015Z"
+ },
+ "members": [
+ {
+ "id": 9,
+ "organisationId": 5,
+ "userId": 9,
+ "role": "owner",
+ "createdAt": "2026-01-29T19:24:07.015Z"
+ }
+ ],
+ "projects": [
+ {
+ "id": 9,
+ "key": "SPNT",
+ "name": "SPRINT",
+ "organisationId": 5,
+ "creatorId": 9
+ }
+ ],
+ "sprints": [
+ {
+ "id": 17,
+ "projectId": 9,
+ "name": "Development 1",
+ "color": "#63d379",
+ "startDate": "2026-01-29T00:00:00.000Z",
+ "endDate": "2026-02-12T23:59:00.000Z",
+ "createdAt": "2026-01-29T20:43:29.814Z"
+ }
+ ],
+ "issues": [
+ {
+ "id": 80,
+ "projectId": 9,
+ "number": 12,
+ "type": "Task",
+ "status": "IN PROGRESS",
+ "title": "Assignee notes",
+ "description": "Users should be able to add assignee notes to represent an assignee's role in an issue.",
+ "creatorId": 9,
+ "sprintId": 17
+ },
+ {
+ "id": 74,
+ "projectId": 9,
+ "number": 6,
+ "type": "Ideation",
+ "status": "TO DO",
+ "title": "Subscriptions should be organisation based",
+ "description": "They shouldn't be user based. A subscription should be for an organisation. Any user should be able to have unlimited organisations, but they are all limited, and charged for separately.",
+ "creatorId": 9,
+ "sprintId": 17
+ },
+ {
+ "id": 73,
+ "projectId": 9,
+ "number": 5,
+ "type": "Ideation",
+ "status": "TO DO",
+ "title": "Rethink \"seats\" payment idea",
+ "description": "Maybe user pays for different tiers? \nSpaces:\n- Free tier: 3\n- Small Team: 10\n- Moderate Team: 50\n- Big Team: Contact us for a price",
+ "creatorId": 9,
+ "sprintId": 17
+ },
+ {
+ "id": 70,
+ "projectId": 9,
+ "number": 2,
+ "type": "Quality of Life",
+ "status": "TO DO",
+ "title": "Streamline issue/create route",
+ "description": "Currently each field is parsed individually (adding a new field requires much more work than it should)",
+ "creatorId": 9,
+ "sprintId": 17
+ },
+ {
+ "id": 71,
+ "projectId": 9,
+ "number": 3,
+ "type": "Task",
+ "status": "TO DO",
+ "title": "Automatically create org + project for new users",
+ "description": "\"ob's organisation\" and \"ob's project\" should be created upon account creation.",
+ "creatorId": 9,
+ "sprintId": 17
+ },
+ {
+ "id": 86,
+ "projectId": 9,
+ "number": 18,
+ "type": "Task",
+ "status": "TO DO",
+ "title": "Share filters button",
+ "description": "Copies a url with organisation, project and all issue filters for reuse or use with team members.",
+ "creatorId": 9,
+ "sprintId": null
+ },
+ {
+ "id": 79,
+ "projectId": 9,
+ "number": 11,
+ "type": "Bug",
+ "status": "DONE",
+ "title": "Filters should persist across refresh",
+ "description": "Use localStorage for this - even switching to the timeline tab and back resets filters, very annoying.",
+ "creatorId": 9,
+ "sprintId": null
+ },
+ {
+ "id": 76,
+ "projectId": 9,
+ "number": 8,
+ "type": "Task",
+ "status": "TO DO",
+ "title": "Issue attachments",
+ "description": "Users should be able to attach files and images to descriptions and comments. (restrict to images at first - resize with sharp)",
+ "creatorId": 9,
+ "sprintId": 17
+ },
+ {
+ "id": 75,
+ "projectId": 9,
+ "number": 7,
+ "type": "Task",
+ "status": "DONE",
+ "title": "Export organisation contents as JSON",
+ "description": "Organisation owner/admins should be able to export all of the data in an organisation as a JSON file. This can then be used to \"Create [an org] from JSON\"",
+ "creatorId": 9,
+ "sprintId": 17
+ },
+ {
+ "id": 78,
+ "projectId": 9,
+ "number": 10,
+ "type": "Task",
+ "status": "TO DO",
+ "title": "Standalone time tracking",
+ "description": "User should be able to create time tracking entries that are not related to an issue. For example: ideation, sprint planning, meetings.",
+ "creatorId": 9,
+ "sprintId": 17
+ },
+ {
+ "id": 72,
+ "projectId": 9,
+ "number": 4,
+ "type": "Bug",
+ "status": "MERGED",
+ "title": "Org avatar scaling weirdly",
+ "description": "In organisations.tsx info tab, the org avatar is shrunk horizontally to fit the text. height is correct though.\n\nThis only occurs when there is no organisation icon.",
+ "creatorId": 9,
+ "sprintId": 17
+ },
+ {
+ "id": 69,
+ "projectId": 9,
+ "number": 1,
+ "type": "Bug",
+ "status": "MERGED",
+ "title": "Unable to create an issue with type",
+ "description": "It chooses the first type available instead of the one chosen by the user",
+ "creatorId": 9,
+ "sprintId": 17
+ },
+ {
+ "id": 81,
+ "projectId": 9,
+ "number": 13,
+ "type": "Task",
+ "status": "TO DO",
+ "title": "User defined colour scheme",
+ "description": "On the backburner for now",
+ "creatorId": 9,
+ "sprintId": null
+ },
+ {
+ "id": 83,
+ "projectId": 9,
+ "number": 15,
+ "type": "Task",
+ "status": "TO DO",
+ "title": "Post-sub email",
+ "description": "Thank you for subscribing",
+ "creatorId": 9,
+ "sprintId": null
+ },
+ {
+ "id": 84,
+ "projectId": 9,
+ "number": 16,
+ "type": "Task",
+ "status": "TO DO",
+ "title": "Trial end warning email",
+ "description": "Your trial is coming to an end. Manage your subscription to renew",
+ "creatorId": 9,
+ "sprintId": null
+ },
+ {
+ "id": 82,
+ "projectId": 9,
+ "number": 14,
+ "type": "Task",
+ "status": "TO DO",
+ "title": "Welcome email",
+ "description": "Welcome to sprint, this is what you can do: ...",
+ "creatorId": 9,
+ "sprintId": null
+ },
+ {
+ "id": 85,
+ "projectId": 9,
+ "number": 17,
+ "type": "Task",
+ "status": "TO DO",
+ "title": "Trial system",
+ "description": "14 day trial of Team plan, any elevated features must be locked away again after trial ends.\n\nIf the organisation has more members than free plan allows, the members who were added after will be barred from accessing the organisation. Additional projects, issues and sprints will be locked away.",
+ "creatorId": 9,
+ "sprintId": null
+ },
+ {
+ "id": 77,
+ "projectId": 9,
+ "number": 9,
+ "type": "Task",
+ "status": "DONE",
+ "title": "Add \"assign to me by default\"",
+ "description": "",
+ "creatorId": 9,
+ "sprintId": 17
+ }
+ ],
+ "issueAssignees": [
+ {
+ "id": 73,
+ "issueId": 69,
+ "userId": 9,
+ "assignedAt": "2026-01-29T20:33:42.827Z"
+ },
+ {
+ "id": 74,
+ "issueId": 70,
+ "userId": 9,
+ "assignedAt": "2026-01-29T20:44:51.258Z"
+ },
+ {
+ "id": 75,
+ "issueId": 71,
+ "userId": 9,
+ "assignedAt": "2026-01-29T20:46:34.959Z"
+ },
+ {
+ "id": 76,
+ "issueId": 72,
+ "userId": 9,
+ "assignedAt": "2026-01-29T20:49:12.879Z"
+ },
+ {
+ "id": 77,
+ "issueId": 73,
+ "userId": 9,
+ "assignedAt": "2026-01-29T21:21:14.628Z"
+ },
+ {
+ "id": 78,
+ "issueId": 77,
+ "userId": 9,
+ "assignedAt": "2026-01-29T21:39:58.232Z"
+ },
+ {
+ "id": 79,
+ "issueId": 75,
+ "userId": 9,
+ "assignedAt": "2026-01-29T23:09:09.537Z"
+ },
+ {
+ "id": 80,
+ "issueId": 79,
+ "userId": 9,
+ "assignedAt": "2026-01-29T23:09:12.591Z"
+ },
+ {
+ "id": 81,
+ "issueId": 80,
+ "userId": 9,
+ "assignedAt": "2026-01-29T23:09:15.541Z"
+ },
+ {
+ "id": 82,
+ "issueId": 86,
+ "userId": 9,
+ "assignedAt": "2026-01-29T23:19:57.419Z"
+ }
+ ],
+ "issueComments": [
+ {
+ "id": 70,
+ "issueId": 69,
+ "userId": 9,
+ "body": "The backend wasn't reading the type field. We need to streamline that route.",
+ "createdAt": "2026-01-29T20:44:16.540Z",
+ "updatedAt": "2026-01-29T20:44:16.540Z"
+ },
+ {
+ "id": 71,
+ "issueId": 69,
+ "userId": 9,
+ "body": "Created https://sprintpm.org/issues?o=org&p=spnt&i=2&modal=true for the previously mentioned issue",
+ "createdAt": "2026-01-29T20:45:23.285Z",
+ "updatedAt": "2026-01-29T20:45:23.285Z"
+ },
+ {
+ "id": 72,
+ "issueId": 69,
+ "userId": 9,
+ "body": "SPNT-002",
+ "createdAt": "2026-01-29T20:45:38.161Z",
+ "updatedAt": "2026-01-29T20:45:38.161Z"
+ },
+ {
+ "id": 73,
+ "issueId": 80,
+ "userId": 9,
+ "body": "ugh",
+ "createdAt": "2026-01-30T00:34:45.745Z",
+ "updatedAt": "2026-01-30T00:34:45.745Z"
+ }
+ ],
+ "timedSessions": [
+ {
+ "id": 1,
+ "userId": 9,
+ "issueId": 69,
+ "timestamps": [
+ "2026-01-29T20:37:38.485Z",
+ "2026-01-29T20:42:58.198Z"
+ ],
+ "endedAt": "2026-01-29T20:42:59.566Z",
+ "createdAt": "2026-01-29T20:37:38.486Z"
+ },
+ {
+ "id": 2,
+ "userId": 9,
+ "issueId": 72,
+ "timestamps": [
+ "2026-01-29T20:49:45.879Z",
+ "2026-01-29T21:16:34.178Z"
+ ],
+ "endedAt": "2026-01-29T21:16:34.178Z",
+ "createdAt": "2026-01-29T20:49:45.879Z"
+ },
+ {
+ "id": 3,
+ "userId": 9,
+ "issueId": 77,
+ "timestamps": [
+ "2026-01-29T21:40:01.096Z",
+ "2026-01-29T23:09:19.693Z"
+ ],
+ "endedAt": "2026-01-29T23:09:19.693Z",
+ "createdAt": "2026-01-29T21:40:01.096Z"
+ },
+ {
+ "id": 6,
+ "userId": 9,
+ "issueId": 80,
+ "timestamps": [
+ "2026-01-29T23:09:16.096Z",
+ "2026-01-30T00:34:30.575Z"
+ ],
+ "endedAt": "2026-01-30T00:34:30.575Z",
+ "createdAt": "2026-01-29T23:09:16.096Z"
+ },
+ {
+ "id": 5,
+ "userId": 9,
+ "issueId": 79,
+ "timestamps": [
+ "2026-01-29T23:09:13.291Z",
+ "2026-01-30T00:34:30.923Z"
+ ],
+ "endedAt": "2026-01-30T00:34:30.923Z",
+ "createdAt": "2026-01-29T23:09:13.291Z"
+ },
+ {
+ "id": 4,
+ "userId": 9,
+ "issueId": 75,
+ "timestamps": [
+ "2026-01-29T23:09:10.375Z",
+ "2026-01-30T00:34:31.391Z"
+ ],
+ "endedAt": "2026-01-30T00:34:31.391Z",
+ "createdAt": "2026-01-29T23:09:10.376Z"
+ }
+ ],
+ "_metadata": {
+ "exportedAt": "2026-01-30T00:42:40.425Z",
+ "exportedBy": 9,
+ "version": "1.0"
+ }
+}
\ No newline at end of file
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index 755395e..f2ef600 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -36,6 +36,8 @@ const main = async () => {
"/": withGlobal(() => new Response(`title: tnirps\ndev-mode: ${DEV}\nport: ${PORT}`)),
"/health": withGlobal(() => new Response("OK")),
+ "/ai/chat": withGlobalAuthed(withAuth(routes.aiChat)),
+
// routes that modify state require withCSRF middleware
"/auth/register": withGlobal(routes.authRegister),
"/auth/login": withGlobal(routes.authLogin),
diff --git a/packages/backend/src/routes/ai/chat.ts b/packages/backend/src/routes/ai/chat.ts
new file mode 100644
index 0000000..be9b2e3
--- /dev/null
+++ b/packages/backend/src/routes/ai/chat.ts
@@ -0,0 +1,26 @@
+import { ChatRequestSchema } from "@sprint/shared";
+import type { AuthedRequest } from "../../auth/middleware";
+import { getUserById } from "../../db/queries";
+import { errorResponse, parseQueryParams } from "../../validation";
+import { buildContext, SYSTEM_PROMPT } from "./context-builders";
+import { callAI } from "./opencode";
+
+export default async function aiChat(req: AuthedRequest) {
+ const url = new URL(req.url);
+ const parsed = parseQueryParams(url, ChatRequestSchema);
+ if ("error" in parsed) return parsed.error;
+
+ const { orgId, projectId, message } = parsed.data;
+
+ const user = await getUserById(req.userId);
+ if (!user) {
+ return errorResponse("user not found", "USER_NOT_FOUND", 404);
+ }
+
+ const context = await buildContext(orgId, projectId, user);
+
+ const fullPrompt = `${SYSTEM_PROMPT}\n\n${context}\n\n${message}`;
+ const response = await callAI(fullPrompt);
+
+ return Response.json(response);
+}
diff --git a/packages/backend/src/routes/ai/context-builders.ts b/packages/backend/src/routes/ai/context-builders.ts
new file mode 100644
index 0000000..9001b1d
--- /dev/null
+++ b/packages/backend/src/routes/ai/context-builders.ts
@@ -0,0 +1,98 @@
+import fs from "node:fs";
+import path from "node:path";
+import type { UserRecord } from "@sprint/shared";
+import {
+ getIssuesWithUsersByProject,
+ getOrganisationById,
+ getProjectByID,
+ getProjectsByOrganisationId,
+ getSprintsByProject,
+} from "../../db/queries";
+
+export const SYSTEM_PROMPT = fs.readFileSync(path.join(import.meta.dir, "system-prompt.xml"), "utf-8");
+
+export const buildContext = async (orgId: number, projectId: number, user: UserRecord) => {
+ // fetch organisation, projects, sprints, issues, and issue assignees
+ // db queries
+ const organisation = await getOrganisationById(orgId);
+ if (!organisation) {
+ return "";
+ }
+ const projects = await getProjectsByOrganisationId(orgId);
+ const project = await getProjectByID(projectId);
+ if (!project) {
+ return "";
+ }
+ const issues = await getIssuesWithUsersByProject(projectId);
+ const sprints = await getSprintsByProject(projectId);
+
+ const assignedIssues = issues.filter((i) => i.Assignees.some((a) => a.id === user.id));
+
+ const byStatus = (status: string) => assignedIssues.filter((i) => i.Issue.status === status);
+
+ return `
+
+
+
+
+
+${Object.entries(organisation.statuses)
+ .map(([name, color]) => ` `)
+ .join("\n")}
+
+
+
+
+${projects.map((p) => ` `).join("\n")}
+
+
+
+${sprints.map((s) => ` `).join("\n")}
+
+
+
+${assignedIssues.map((i) => ` `).join("\n")}
+
+
+
+
+${byStatus("TO DO")
+ .map(
+ (i) =>
+ ` `,
+ )
+ .join("\n")}
+
+
+${byStatus("IN PROGRESS")
+ .map(
+ (i) =>
+ ` `,
+ )
+ .join("\n")}
+
+
+${byStatus("DONE")
+ .map(
+ (i) =>
+ ` `,
+ )
+ .join("\n")}
+
+
+
+
+${assignedIssues
+ .map(
+ (i) => `
+ ${i.Issue.title.replace(/"/g, """)}
+ ${(i.Issue.description || "").replace(/"/g, """)}
+ ${i.Issue.status}
+ ${i.Issue.type}
+ ${sprints.find((s) => s.id === i.Issue.sprintId)?.name || "None"}
+ `,
+ )
+ .join("\n")}
+
+`;
+};
diff --git a/packages/backend/src/routes/ai/opencode.ts b/packages/backend/src/routes/ai/opencode.ts
new file mode 100644
index 0000000..cb4b8fe
--- /dev/null
+++ b/packages/backend/src/routes/ai/opencode.ts
@@ -0,0 +1,65 @@
+export type AIResponse = {
+ text: string;
+ highlighted_issues: number[];
+ suggested_actions: string[] | null;
+ raw: string;
+};
+
+export const callAI = async (prompt: string): Promise => {
+ const result = Bun.spawn(["opencode", "run", prompt, "--model", "opencode/kimi-k2.5-free"], {
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+
+ // Collect all output
+ let rawOutput = "";
+ for await (const chunk of result.stdout) {
+ rawOutput += new TextDecoder().decode(chunk);
+ }
+
+ let stderrOutput = "";
+ for await (const chunk of result.stderr) {
+ stderrOutput += new TextDecoder().decode(chunk);
+ }
+
+ await result.exited;
+
+ try {
+ const jsonMatch = rawOutput.match(/\{[\s\S]*\}/);
+ const jsonStr = jsonMatch ? jsonMatch[0] : rawOutput;
+
+ const response = JSON.parse(jsonStr);
+
+ if (!response.text || !Array.isArray(response.highlighted_issues)) {
+ throw new Error("Invalid response structure");
+ }
+
+ const output = {
+ text: response.text,
+ highlighted_issues: response.highlighted_issues,
+ suggested_actions: response.suggested_actions || [],
+ raw: rawOutput,
+ };
+
+ return output;
+ } catch (e) {
+ console.log(
+ JSON.stringify(
+ {
+ error: "Failed to parse AI response as JSON",
+ parse_error: e instanceof Error ? e.message : String(e),
+ raw: rawOutput,
+ stderr: stderrOutput,
+ },
+ null,
+ 2,
+ ),
+ );
+ return {
+ text: "Sorry, an error occurred while processing the AI response.",
+ highlighted_issues: [],
+ suggested_actions: [],
+ raw: rawOutput,
+ };
+ }
+};
diff --git a/packages/backend/src/routes/ai/system-prompt.xml b/packages/backend/src/routes/ai/system-prompt.xml
new file mode 100644
index 0000000..7165ddc
--- /dev/null
+++ b/packages/backend/src/routes/ai/system-prompt.xml
@@ -0,0 +1,124 @@
+
+
+
+ Sprinter
+ AI assistant in a project management tool
+
+
+
+
+ never reference
+ organisation name
+ URL-friendly identifier
+ maps status names to colors
+ maps types to icons/colors
+
+
+
+ never reference
+ short project key
+ project name
+
+
+
+ never reference
+ sprint name
+ start date
+ end date
+
+
+
+ use ONLY for highlight array, never in text
+ use #number format in text
+ Task, Bug, Feature, etc.
+ TO DO, IN PROGRESS, DONE, etc.
+ issue title
+ detailed description
+ never reference
+
+
+
+ never reference
+ maps to issue.id
+ maps to user
+
+
+
+
+ OUTPUT MUST BE VALID JSON ONLY - no markdown, no explanations before/after
+ NEVER output internal IDs (id, userId, creatorId, organisationId, projectId, sprintId) in the text field
+ ALWAYS use #<number> format when referring to issues in the text field
+ Every issue mentioned in text MUST have its id in the highlighted_issues array
+
+
+
+ Respond with a single JSON object. No other text.
+
+{
+ "text": "Your response text here. Use #number format for issues.",
+ "highlighted_issues": [71, 84, 93],
+ "suggested_actions": []
+}
+
+
+ The response shown to the user. Be concise. Never use "You have" or "There are". Just state facts. Use #number format for issues.
+ Array of issue IDs mentioned in text. Include every issue referenced. Empty array if no issues mentioned.
+ Array of suggested actions (empty for now). Future: navigation suggestions, filter suggestions.
+
+
+
+
+
+ show me my done issues
+
+
+
+
+ how many issues do i have
+
+
+
+
+ tell me about issue 12
+
+
+
+
+ what's the login feature status
+
+
+
+
+ what should i work on today
+
+
+
+
+
+ Never start responses with "You have" or "There are"
+ Never use phrases like "based on", "I can see", "according to"
+ Never ask follow-up questions
+ Use numerals only (5, not five)
+ Be concise - under 200 words
+
+
diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts
index 7ba6e1e..b2144b3 100644
--- a/packages/backend/src/routes/index.ts
+++ b/packages/backend/src/routes/index.ts
@@ -1,3 +1,4 @@
+import aiChat from "./ai/chat";
import authLogin from "./auth/login";
import authLogout from "./auth/logout";
import authMe from "./auth/me";
@@ -56,6 +57,8 @@ import userUpdate from "./user/update";
import userUploadAvatar from "./user/upload-avatar";
export const routes = {
+ aiChat,
+
authRegister,
authLogin,
authLogout,
diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts
index b16c42c..ca05624 100644
--- a/packages/shared/src/api-schemas.ts
+++ b/packages/shared/src/api-schemas.ts
@@ -666,3 +666,9 @@ export const CancelSubscriptionResponseSchema = z.object({
});
export type CancelSubscriptionResponse = z.infer;
+
+export const ChatRequestSchema = z.object({
+ orgId: z.coerce.number().int().positive("orgId must be a positive integer"),
+ projectId: z.coerce.number().int().positive("projectId must be a positive integer"),
+ message: z.string().min(1, "Message is required"),
+});
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index ef65a17..428bdd2 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -135,6 +135,7 @@ export {
UserResponseSchema,
UserUpdateRequestSchema,
VerifyEmailRequestSchema,
+ ChatRequestSchema,
} from "./api-schemas";
export {
ISSUE_COMMENT_MAX_LENGTH,