diff --git a/.github/workflows/core-build.yml b/.github/workflows/core-build.yml
index 2d52aa8e..e8531a4e 100644
--- a/.github/workflows/core-build.yml
+++ b/.github/workflows/core-build.yml
@@ -11,6 +11,11 @@ jobs:
with:
node-version: 20
- run: npm ci
+ - name: Verify skill bodies are in sync with the seed migration
+ # Fails if a .claude/skills/**/SKILL.md changed without regenerating the
+ # seed migration (npm run skills:generate). Keeps prod skill content from
+ # silently drifting out of sync. No DB needed — reads files + manifest.
+ run: npm run skills:check
- name: Reset extensions config
run: echo '{"extensions":[]}' > extensions.config.json
- run: npm run setup:extensions
diff --git a/.gitignore b/.gitignore
index 68b4dcef..ce2e628f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -66,3 +66,5 @@ supabase/.temp/
# The empty defaults in lib/extensions/_generated/ are committed so core compiles
# out of the box without running the generator.
supabase/.branches/
+
+scripts\remap-krister-bas96-to-bas2025.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 7c60165d..dfed621a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,6 +21,8 @@ npm run lint # ESLint
npm test # Run all Vitest tests
npx vitest run
# Run tests in a specific directory
npm run setup:extensions # Regenerate extension registry from extensions.config.json
+npm run skills:generate # Regenerate agent_atom_registry seed migration from .claude/skills/**/SKILL.md (after editing a SKILL.md)
+npm run skills:check # CI guard: fail if a SKILL.md changed without regenerating the seed migration
```
---
@@ -307,6 +309,8 @@ export async function POST(request: Request) {
7. Apply via Supabase MCP `apply_migration`
8. Always end with `NOTIFY pgrst, 'reload schema'` when altering table structure
+**Agent skill bodies (`agent_atom_registry`)**: skill content is authored in `.claude/skills/**/SKILL.md` and inlined into the DB `body` column at runtime (not read from disk — that doesn't bundle on Vercel/Docker). After editing any SKILL.md, run `npm run skills:generate` to emit a new `*_seed_agent_atom_bodies.sql` migration and commit it; `npm run skills:check` (wired into CI) fails the build if you forget. The MCP server exposes only atoms with `mcp_exposed = true` (swarm-* audit skills are never atoms).
+
---
## Skills, Git & CI
diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx
index 300f9fdb..02846d85 100644
--- a/app/(dashboard)/bookkeeping/page.tsx
+++ b/app/(dashboard)/bookkeeping/page.tsx
@@ -137,12 +137,14 @@ export default function BookkeepingPage() {
+
+
+
}
/>
diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx
index 681d5fbd..ad866f2e 100644
--- a/app/(dashboard)/bookkeeping/year-end/page.tsx
+++ b/app/(dashboard)/bookkeeping/year-end/page.tsx
@@ -16,6 +16,7 @@ import {
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, Lock } from 'lucide-react'
+import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -200,14 +201,22 @@ export default function YearEndPage() {
return (
-
+
Årsbokslut
-
+
+
+
+
{periods === null && !periodsError && (
diff --git a/app/(dashboard)/chat/[id]/page.tsx b/app/(dashboard)/chat/[id]/page.tsx
new file mode 100644
index 00000000..0f391f69
--- /dev/null
+++ b/app/(dashboard)/chat/[id]/page.tsx
@@ -0,0 +1,65 @@
+import { createClient } from '@/lib/supabase/server'
+import { notFound, redirect } from 'next/navigation'
+import { getActiveCompanyId } from '@/lib/company/context'
+import ChatConversationView from '@/components/agent/ChatConversationView'
+
+export const dynamic = 'force-dynamic'
+
+interface PageProps {
+ params: Promise<{ id: string }>
+}
+
+// /chat/[id] — server-renders the conversation row + ordered messages, then
+// hydrates the client AgentChat with them so the user can continue typing
+// against the existing conversation_id. The agent loop on the server picks
+// up via /api/agent/invoke with conversation_id supplied.
+export default async function ChatConversationPage({ params }: PageProps) {
+ const { id } = await params
+ const supabase = await createClient()
+
+ const { data: { user } } = await supabase.auth.getUser()
+ if (!user) redirect('/login')
+
+ const companyId = await getActiveCompanyId(supabase, user.id)
+ if (!companyId) redirect('/onboarding')
+
+ const { data: conversation } = await supabase
+ .from('agent_conversations')
+ .select('id, intent_id, context_ref, title, pinned, archived, last_message_at')
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (!conversation) notFound()
+
+ const { data: messages } = await supabase
+ .from('agent_messages')
+ .select('role, content, hidden, created_at')
+ .eq('conversation_id', id)
+ .order('created_at', { ascending: true })
+
+ return (
+
+ )
+}
+
+function intentLabel(intentId: string): string {
+ switch (intentId) {
+ case 'general.help':
+ return 'Fråga din assistent'
+ case 'transaction.categorization':
+ return 'Hjälp med transaktion'
+ case 'invoice.draft':
+ return 'Hjälp med faktura'
+ case 'supplier_invoice.review':
+ return 'Granska leverantörsfaktura'
+ default:
+ return intentId
+ }
+}
diff --git a/app/(dashboard)/chat/intake/page.tsx b/app/(dashboard)/chat/intake/page.tsx
new file mode 100644
index 00000000..6bf80036
--- /dev/null
+++ b/app/(dashboard)/chat/intake/page.tsx
@@ -0,0 +1,24 @@
+import { createClient } from '@/lib/supabase/server'
+import { redirect } from 'next/navigation'
+import { getActiveCompanyId } from '@/lib/company/context'
+import ChatIntakeStarter from '@/components/agent/ChatIntakeStarter'
+
+export const dynamic = 'force-dynamic'
+
+// /chat/intake — Phase C bootstrap surface. ReviewCard navigates here after
+// Phase B "kör" succeeds. The client component mounts AgentChat with
+// intent='onboarding.intake' in fresh-start mode; AgentChat auto-fires the
+// first invoke which creates the conversation row, and we swap the URL to
+// /chat/[id] when the new id streams back.
+//
+// Plan ref: dev_docs/specialized-agent-plan.md §7 Phase C.
+export default async function ChatIntakePage() {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+ if (!user) redirect('/login')
+
+ const companyId = await getActiveCompanyId(supabase, user.id)
+ if (!companyId) redirect('/onboarding')
+
+ return
+}
diff --git a/app/(dashboard)/chat/layout.tsx b/app/(dashboard)/chat/layout.tsx
new file mode 100644
index 00000000..1883ec0b
--- /dev/null
+++ b/app/(dashboard)/chat/layout.tsx
@@ -0,0 +1,54 @@
+import { createClient } from '@/lib/supabase/server'
+import { redirect } from 'next/navigation'
+import { getActiveCompanyId } from '@/lib/company/context'
+import ChatSidebar from '@/components/agent/ChatSidebar'
+
+export const dynamic = 'force-dynamic'
+
+// Two-pane chat layout: sidebar with conversations on the left, active
+// conversation (or empty state) in the main panel. Both /chat and /chat/[id]
+// share this layout so the sidebar doesn't unmount on conversation switches.
+export default async function ChatLayout({ children }: { children: React.ReactNode }) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+ if (!user) redirect('/login')
+
+ const companyId = await getActiveCompanyId(supabase, user.id)
+ if (!companyId) redirect('/onboarding')
+
+ // Block the chat surface until the agent is built. Without this a user
+ // who deep-links to /chat (bookmark, ⌘K, "+ Ny" elsewhere) lands on an
+ // empty conversations list with no Anna to talk to. The home route at /
+ // renders NewUserChecklist for the same state, so we forward there
+ // instead of duplicating the welcome screen here.
+ const { data: agent } = await supabase
+ .from('agent_profiles')
+ .select('verified_at')
+ .eq('company_id', companyId)
+ .maybeSingle()
+ if (!agent?.verified_at) redirect('/')
+
+ const { data: conversations } = await supabase
+ .from('agent_conversations')
+ .select(
+ 'id, intent_id, context_ref, title, pinned, archived, last_message_at, last_message_preview, created_at',
+ )
+ .eq('company_id', companyId)
+ .eq('archived', false)
+ .order('pinned', { ascending: false })
+ .order('last_message_at', { ascending: false, nullsFirst: false })
+ .limit(100)
+
+ return (
+ // MainContainer hands /chat a full-bleed h-full wrapper, so we don't
+ // need negative margins to break out of any chrome padding.
+ //
+ // dvh handles mobile browser chrome shrinking on scroll. Mobile: subtract
+ // the bottom nav (h-16 = 64px) + safe-area-inset-bottom so the chat
+ // pane fills the visible viewport exactly. Desktop: full viewport.
+
+
+
{children}
+
+ )
+}
diff --git a/app/(dashboard)/chat/new/page.tsx b/app/(dashboard)/chat/new/page.tsx
new file mode 100644
index 00000000..1de7cecf
--- /dev/null
+++ b/app/(dashboard)/chat/new/page.tsx
@@ -0,0 +1,36 @@
+import { createClient } from '@/lib/supabase/server'
+import { redirect } from 'next/navigation'
+import { getActiveCompanyId } from '@/lib/company/context'
+import ChatNewStarter from '@/components/agent/ChatNewStarter'
+import { getIntent } from '@/lib/agent/intents/registry'
+
+export const dynamic = 'force-dynamic'
+
+interface PageProps {
+ searchParams: Promise<{ intent?: string; prompt?: string }>
+}
+
+// /chat/new — generic conversation bootstrap. Reads ?intent= and ?prompt=
+// from the URL and mounts AgentChat in fresh mode; AgentChat creates the
+// conversation server-side on first invoke and the client swaps the URL
+// to /chat/[id] when the id streams back. Mirrors /chat/intake but with
+// caller-chosen intent/seed, so suggestion chips and ⌘K can route here
+// inline instead of opening the slide-in sheet.
+export default async function ChatNewPage({ searchParams }: PageProps) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+ if (!user) redirect('/login')
+
+ const companyId = await getActiveCompanyId(supabase, user.id)
+ if (!companyId) redirect('/onboarding')
+
+ const sp = await searchParams
+ const requested = typeof sp.intent === 'string' && sp.intent.trim() ? sp.intent.trim() : 'general.help'
+ // Validate against the registry — a bogus ?intent= would otherwise render the
+ // chat shell and then fail at invoke with a 400, which reads as "Anna is broken"
+ // rather than "bad link". Fall back to general help instead.
+ const intent = getIntent(requested) ? requested : 'general.help'
+ const prompt = typeof sp.prompt === 'string' ? sp.prompt : ''
+
+ return
+}
diff --git a/app/(dashboard)/chat/page.tsx b/app/(dashboard)/chat/page.tsx
new file mode 100644
index 00000000..06348c80
--- /dev/null
+++ b/app/(dashboard)/chat/page.tsx
@@ -0,0 +1,10 @@
+import ChatEmptyState from '@/components/agent/ChatEmptyState'
+
+export const dynamic = 'force-dynamic'
+
+// Empty state for /chat. The sidebar (in the layout) shows the list; this
+// view appears when no specific conversation is selected. Offers an obvious
+// entry to start a fresh general.help conversation.
+export default function ChatIndexPage() {
+ return
+}
diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx
index de42d711..c189b26f 100644
--- a/app/(dashboard)/invoices/new/page.tsx
+++ b/app/(dashboard)/invoices/new/page.tsx
@@ -30,6 +30,7 @@ import CustomerForm from '@/components/customers/CustomerForm'
import { BankDetailsSetupDialog } from '@/components/invoices/BankDetailsSetupDialog'
import { FirstInvoiceLogoPrompt } from '@/components/invoices/FirstInvoiceLogoPrompt'
import { useCompany } from '@/contexts/CompanyContext'
+import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import {
ROT_WORK_TYPES,
RUT_WORK_TYPES,
@@ -599,7 +600,7 @@ export default function NewInvoicePage() {
-