feat(onboarding): company setup from the conversation and POST /api/v1/companies (#1814 PR 3) (#1864)

* feat(onboarding): company setup from the conversation and POST /api/v1/companies

Third PR of agent-first onboarding (#1814). Once connected, the agent can
now set up a company end to end without the web wizard, and partner
platforms can provision companies over REST.

- create_company_for_user: service-role-only SECURITY DEFINER twin of
  create_company_with_owner taking the owner explicitly (service clients
  have no auth.uid()). pg-real test covers creation, role gating, unknown
  owner and foreign team.
- lib/company/create-company.ts: the wizard's creation sequence (org
  number, TIC snapshot, BAS chart, settings, first fiscal period, tax
  deadlines, rollback) extracted into createCompanyCore; the Server
  Action delegates to it, behaviour unchanged.
- lib/company/onboarding-input.ts: one Zod schema + planner for the
  agent/API paths; a VAT-registered company without moms_period is
  refused (a missing period silently yields zero VAT deadlines).
- MCP: gnubok_create_company (two-phase: preview, then confirm=true;
  companies:write, company-independent), gnubok_connect_bank and
  gnubok_connect_skatteverket (status + the browser link, gated on
  bank_sync / skatteverket, search-only in the catalog), the
  "onboarding" skill, and initialize instructions pointing at it.
- Consent page pre-ticks companies:write for an account with no company
  yet, so the setup does not dead-end on insufficient scope after signup.
- POST /api/v1/companies (companies:write, dry-run aware) on the same
  core; scope map, registry, spec snapshot and the generated API skill
  updated.
- tools/list payload ceiling raised 59.95K -> 60.4K for the one new
  default-catalog tool (documented in the guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(onboarding): explicit f_skatt, org number when VAT-registered, EF first year ends 31 Dec

Review findings on #1864 (Swedish compliance review):
- f_skatt is required, never defaulted to approved (SE-R-005 risk).
- org_number is required when vat_registered: the invoice
  momsregistreringsnummer derives from it (ML 17 kap 24 §).
- An enskild firma's first fiscal year must end on 31 December and its
  start month is forced to 1 even with first_fiscal_year set, mirroring
  the wizard's own rule text (BFL 3 kap. 1 §).
- POST /api/v1/companies no longer claims Idempotency-Key support (the
  wrapper only honours it on company-scoped routes).
- pg-real: createCompanyCore's chart seed runs under the real
  service_role, which the unit tests could not prove.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* test(pg): starter chart has 41 accounts, assert non-empty

The service_role chart-seed proof passed the part that mattered (no
42501 from seed_chart_of_accounts) and failed on a wrong row-count
guess: the seeded chart is a curated starter set, not the full BAS list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(migrations): move create_company_for_user to 20260825120000

main gained 20260824170000_bulk_book_transactions_service_actor.sql with
the same version while this branch was open; two files on one version
abort every Supabase branch apply and the prod auto-apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* chore(api): refresh spec snapshot and generated skill after rebasing onto main

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(mcp): flat create_company result, refuse localhost connect links, test hygiene

CodeRabbit on #1864: the confirmed-create result was wrapped in the
{ data, next } envelope while its outputSchema promised top-level
fields; it now returns the fields with next as a sibling. The two
connect-link tools refuse to build a link when NEXT_PUBLIC_APP_URL is
unset instead of handing a remote user a localhost URL. Tests clear
mocks and the event bus in beforeEach. Not changed: the rollback
already survives user_preferences.active_company_id (that FK is ON
DELETE SET NULL since 20260331010000), and v1 error details stay in the
surface's English developer convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-25 12:41:02 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent c121d27996
commit 31e0cd6e05
25 changed files with 1920 additions and 183 deletions
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { eventBus } from '@/lib/events/bus'
import { MCP_TOOL_CAPABILITY_MAP } from '@/lib/entitlements/keys'
import { tools } from '../server'
// Onboarding connect-link tools (issue #1814 PR 3): status + the browser link
// the user opens. Both flows need a cookie session and BankID in a browser,
// so the tools never try to drive them.
const COMPANY_ID = '11111111-1111-4111-8111-111111111111'
const bankTool = tools.find((t) => t.name === 'gnubok_connect_bank')!
const skvTool = tools.find((t) => t.name === 'gnubok_connect_skatteverket')!
function listClient(rows: unknown[] | null, error: unknown = null) {
const chain: Record<string, ReturnType<typeof vi.fn>> = {
select: vi.fn(() => chain),
eq: vi.fn(() => chain),
in: vi.fn(() => chain),
order: vi.fn(() => chain),
limit: vi.fn(() => chain),
maybeSingle: vi.fn().mockResolvedValue({ data: rows?.[0] ?? null, error }),
then: (resolve: (v: unknown) => void) => resolve({ data: rows, error }),
}
return { from: vi.fn(() => chain), chain }
}
describe('onboarding connect-link tools', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.test')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('are read-only companies:read tools gated on the capability their link needs', () => {
expect(TOOL_SCOPE_MAP.gnubok_connect_bank).toBe('companies:read')
expect(TOOL_SCOPE_MAP.gnubok_connect_skatteverket).toBe('companies:read')
expect(MCP_TOOL_CAPABILITY_MAP.gnubok_connect_bank).toBe('bank_sync')
expect(MCP_TOOL_CAPABILITY_MAP.gnubok_connect_skatteverket).toBe('skatteverket')
expect(bankTool.annotations.readOnlyHint).toBe(true)
expect(skvTool.annotations.readOnlyHint).toBe(true)
})
it('bank: reports no connection and hands out the PSD2 import link', async () => {
const { from, chain } = listClient([])
const result = (await bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
expect(result.connected).toBe(false)
expect(result.connect_url).toBe('https://app.example.test/import?mode=psd2')
expect(chain.eq).toHaveBeenCalledWith('company_id', COMPANY_ID)
})
it('bank: reports an active connection', async () => {
const { from } = listClient([
{ id: 'c1', bank_name: 'Swedbank', status: 'active', created_at: '2026-08-01T00:00:00Z' },
])
const result = (await bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
expect(result.connected).toBe(true)
expect(result.connections).toEqual([
{ connection_id: 'c1', bank: 'Swedbank', status: 'active', since: '2026-08-01T00:00:00Z' },
])
})
it('skatteverket: hands out the authorize link when enabled and not connected', async () => {
vi.stubEnv('SKATTEVERKET_ENABLED', 'true')
const { from } = listClient([])
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
expect(result.available).toBe(true)
expect(result.connected).toBe(false)
expect(result.connect_url).toBe(
'https://app.example.test/api/extensions/ext/skatteverket/authorize?return_to=%2F'
)
})
it('skatteverket: reports connected with the token expiry', async () => {
vi.stubEnv('SKATTEVERKET_ENABLED', 'true')
const { from } = listClient([{ expires_at: '2026-12-01T00:00:00Z' }])
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
expect(result.connected).toBe(true)
expect(result.token_expires_at).toBe('2026-12-01T00:00:00Z')
})
it('refuses to hand out a link when NEXT_PUBLIC_APP_URL is not configured', async () => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', '')
const { from } = listClient([])
await expect(bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)).rejects.toMatchObject({
code: 'INTERNAL_ERROR',
})
})
it('skatteverket: says so when the integration is disabled on the installation', async () => {
vi.stubEnv('SKATTEVERKET_ENABLED', 'false')
const { from } = listClient([])
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
expect(result.available).toBe(false)
expect(result.connect_url).toBeNull()
})
})
@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { eventBus } from '@/lib/events/bus'
const mocks = vi.hoisted(() => ({
createCompanyCore: vi.fn(),
}))
vi.mock('@/lib/company/create-company', () => ({
createCompanyCore: (...args: unknown[]) => mocks.createCompanyCore(...args),
}))
import { tools } from '../server'
import { isCompanyDependentTool } from '../company-routing'
const tool = tools.find((t) => t.name === 'gnubok_create_company')!
const TEAM_ID = '44444444-4444-4444-8444-444444444444'
const COMPANY_ID = '55555555-5555-4555-8555-555555555555'
function supabaseWithTeam(teamId: string | null) {
const chain: Record<string, ReturnType<typeof vi.fn>> = {
select: vi.fn(() => chain),
eq: vi.fn(() => chain),
order: vi.fn(() => chain),
limit: vi.fn(() => chain),
maybeSingle: vi.fn().mockResolvedValue({ data: teamId ? { team_id: teamId } : null, error: null }),
}
return {
from: vi.fn(() => chain),
rpc: vi.fn().mockResolvedValue({ data: COMPANY_ID, error: null }),
}
}
const setup = {
name: 'Testbolaget AB',
entity_type: 'aktiebolag',
org_number: '556000-0001',
vat_registered: true,
moms_period: 'quarterly',
accounting_method: 'accrual',
f_skatt: true,
}
describe('gnubok_create_company', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('is a companies:write, company-independent write tool', () => {
expect(tool).toBeDefined()
expect(TOOL_SCOPE_MAP.gnubok_create_company).toBe('companies:write')
expect(isCompanyDependentTool('gnubok_create_company')).toBe(false)
expect(tool.annotations.readOnlyHint).toBe(false)
expect(tool.annotations.destructiveHint).toBe(false)
})
it('previews without creating when confirm is not true', async () => {
const supabase = supabaseWithTeam(TEAM_ID)
const result = (await tool.execute(setup, '', 'user-1', supabase as never)) as Record<string, unknown>
expect(result.created).toBe(false)
expect(result.requires_confirmation).toBe(true)
const preview = result.preview as Record<string, unknown>
expect(preview.org_number).toBe('5560000001')
expect(preview.vat_number).toBe('SE556000000101')
expect(preview.team_id).toBe(TEAM_ID)
expect(preview.fiscal_period).toMatchObject({ name: expect.stringContaining('Räkenskapsår') })
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('creates through the service-role RPC for the key user with confirm=true', async () => {
const supabase = supabaseWithTeam(TEAM_ID)
mocks.createCompanyCore.mockImplementation(
async (_client: unknown, _input: unknown, createRow: () => Promise<{ data: unknown; error: unknown }>) => {
const { data } = await createRow()
return { companyId: data as string }
}
)
const result = (await tool.execute(
{ ...setup, confirm: true },
'',
'user-1',
supabase as never
)) as Record<string, unknown>
expect(result.created).toBe(true)
expect(result.company_id).toBe(COMPANY_ID)
expect(supabase.rpc).toHaveBeenCalledWith('create_company_for_user', {
p_user_id: 'user-1',
p_name: 'Testbolaget AB',
p_entity_type: 'aktiebolag',
p_team_id: TEAM_ID,
})
const [, input] = mocks.createCompanyCore.mock.calls[0] as [unknown, Record<string, unknown>]
expect(input.settings).toMatchObject({ moms_period: 'quarterly', vat_registered: true, company_name: 'Testbolaget AB' })
expect((result.next as Record<string, unknown>).tool).toBe('gnubok_load_skill')
})
it('refuses a VAT-registered company without a moms period before touching the database', async () => {
const supabase = supabaseWithTeam(TEAM_ID)
await expect(
tool.execute({ ...setup, moms_period: undefined, confirm: true }, '', 'user-1', supabase as never)
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', message: expect.stringContaining('moms_period') })
expect(supabase.rpc).not.toHaveBeenCalled()
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
})
it('uses an explicit team_id over the default team', async () => {
const supabase = supabaseWithTeam(TEAM_ID)
const other = '66666666-6666-4666-8666-666666666666'
const result = (await tool.execute({ ...setup, team_id: other }, '', 'user-1', supabase as never)) as Record<
string,
unknown
>
expect((result.preview as Record<string, unknown>).team_id).toBe(other)
expect(supabase.from).not.toHaveBeenCalled()
})
it('surfaces a creation failure as a coded error', async () => {
const supabase = supabaseWithTeam(null)
mocks.createCompanyCore.mockResolvedValue({ error: 'Kunde inte skapa kontoplan. Försök igen.' })
await expect(
tool.execute({ ...setup, confirm: true }, '', 'user-1', supabase as never)
).rejects.toMatchObject({ code: 'COMPANY_CREATE_FAILED' })
})
})
@@ -209,9 +209,16 @@ describe('tools/list payload size guard', () => {
// start instead of mid-task. The runtime block is emitted only for
// companies with a connection; this cost is the outputSchema contract
// (~140 tokens), already trimmed to two short description strings.
// * 60.2K to 60.7K with gnubok_create_company (issue #1814 PR 3): a
// default-catalog tool by necessity, since a client that has not
// connected yet can only call what tools/list shows and this is the
// first protected call of agent-driven onboarding. Its contract was
// trimmed to bare property names first (the two connect-link tools
// are search-only); headroom before the change was ~0 after the skatteverket_connection bump, so even the
// bare contract crossed by ~420.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(60_200)
expect(approxTokens).toBeLessThan(60_700)
})
})
@@ -10,6 +10,8 @@ const COMPANY_INDEPENDENT_TOOLS = new Set([
'gnubok_list_skills',
'gnubok_load_skill',
'gnubok_list_companies',
// Creates the company: by definition it runs before one exists.
'gnubok_create_company',
])
/**
+268 -3
View File
@@ -17,6 +17,9 @@ import {
type ApiKeyScope,
} from '@/lib/auth/api-keys'
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import { getCanonicalBaseUrl } from '@/lib/api/v1/base-url'
import { createCompanyCore } from '@/lib/company/create-company'
import { CompanySetupSchema, planCompanySetup } from '@/lib/company/onboarding-input'
import {
ANONYMOUS_METHODS,
ANONYMOUS_RATE_LIMIT,
@@ -157,7 +160,7 @@ import {
hashRequest,
IdempotencyKeyReuseError,
} from '@/lib/api/idempotency'
import { toToolError, type NextActionHint } from './tool-result'
import { toToolError, withNext, type NextActionHint } from './tool-result'
import {
addCompanyToNextHint,
addCompanyToTopLevelNext,
@@ -1433,6 +1436,42 @@ export function isDefaultCatalogTool(tool: { catalogVisibility?: 'default' | 'se
return tool.catalogVisibility !== 'search'
}
/**
* Absolute origin for links the user opens in a browser. A deployment
* without NEXT_PUBLIC_APP_URL falls back to localhost in getCanonicalBaseUrl,
* which would hand a remote user an unusable link: refuse instead.
*/
function connectLinkBaseUrl(): string {
if (!process.env.NEXT_PUBLIC_APP_URL) {
throw Object.assign(
new Error('NEXT_PUBLIC_APP_URL is not configured on this installation; cannot build a connect link'),
{ code: 'INTERNAL_ERROR' }
)
}
return getCanonicalBaseUrl()
}
/**
* The team a company created through the API/MCP path attaches to when the
* caller does not name one: the user's first (usually the silent personal)
* team, mirroring what the web wizard passes. null when the user has no
* team at all; create_company_for_user then leaves team_id NULL.
*/
async function defaultTeamForUser(supabase: SupabaseClient, userId: string): Promise<string | null> {
const { data, error } = await supabase
.from('team_members')
.select('team_id')
.eq('user_id', userId)
.order('created_at', { ascending: true })
.limit(1)
.maybeSingle()
if (error) {
log.warn('default team lookup failed', { error: error.message })
return null
}
return (data?.team_id as string | undefined) ?? null
}
function paginatedSchema(itemsKey: string, itemSchema: Record<string, unknown> = { type: 'object' }) {
return {
type: 'object',
@@ -2902,6 +2941,232 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_create_company',
title: 'Create Company',
description:
'Create a NEW company for the connected user, set up for bookkeeping (chart, settings, first fiscal period, tax deadlines; 30-day trial). Preview first (no confirm), read it back, then confirm=true. Ask, never assume: form, orgnr, VAT + moms period, method. Skill: onboarding.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string', minLength: 1, maxLength: 200 },
entity_type: { type: 'string', enum: ['enskild_firma', 'aktiebolag'] },
org_number: { type: 'string', description: '10 digits; required when VAT-registered' },
vat_registered: { type: 'boolean' },
moms_period: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Required when vat_registered' },
accounting_method: { type: 'string', enum: ['accrual', 'cash'] },
f_skatt: { type: 'boolean' },
fiscal_year_start_month: { type: 'integer', minimum: 1, maximum: 12 },
first_fiscal_year: {
type: 'object',
additionalProperties: false,
properties: { start: { type: 'string' }, end: { type: 'string' } },
required: ['start', 'end'],
description: 'YYYY-MM-DD; first fiscal year only',
},
address_line1: { type: 'string' },
postal_code: { type: 'string' },
city: { type: 'string' },
team_id: { type: 'string', format: 'uuid' },
confirm: { type: 'boolean', description: 'true creates; omitted = preview' },
},
required: ['name', 'entity_type', 'vat_registered', 'accounting_method', 'f_skatt'],
},
outputSchema: {
type: 'object',
properties: {
created: { type: 'boolean' },
requires_confirmation: { type: 'boolean' },
company_id: { type: 'string' },
preview: { type: 'object' },
next: { type: 'object' },
message: { type: 'string' },
},
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, _companyId, userId, supabase) {
const { confirm, ...setup } = args
const parsed = CompanySetupSchema.safeParse(setup)
if (!parsed.success) {
const detail = parsed.error.issues
.map((issue) => `${issue.path.join('.') || 'input'}: ${issue.message}`)
.join('; ')
throw Object.assign(new Error(`Invalid company setup: ${detail}`), { code: 'VALIDATION_ERROR' })
}
const plan = planCompanySetup(parsed.data)
if (!plan.ok) {
throw Object.assign(new Error(`Invalid fiscal period: ${plan.error}`), { code: 'VALIDATION_ERROR' })
}
const teamId = parsed.data.team_id ?? (await defaultTeamForUser(supabase, userId))
const preview = {
name: parsed.data.name,
entity_type: parsed.data.entity_type,
org_number: (plan.input.settings.org_number as string | null) ?? null,
vat_registered: parsed.data.vat_registered,
vat_number: (plan.input.settings.vat_number as string | null) ?? null,
moms_period: parsed.data.vat_registered ? parsed.data.moms_period ?? null : null,
accounting_method: parsed.data.accounting_method,
f_skatt: parsed.data.f_skatt,
fiscal_period: plan.fiscalPeriod,
team_id: teamId,
}
if (confirm !== true) {
return {
created: false,
requires_confirmation: true,
preview,
message:
'Nothing was created. Read the preview back to the user (especially the fiscal period dates and the VAT setup), then call gnubok_create_company again with the same arguments and confirm=true.',
}
}
const result = await createCompanyCore(supabase, plan.input, () =>
supabase.rpc('create_company_for_user', {
p_user_id: userId,
p_name: parsed.data.name,
p_entity_type: parsed.data.entity_type,
p_team_id: teamId,
})
)
if (result.error !== undefined) {
const code = result.error === 'org_number_invalid' ? 'VALIDATION_ERROR' : 'COMPANY_CREATE_FAILED'
throw Object.assign(new Error(result.error), { code })
}
return {
created: true,
company_id: result.companyId,
...preview,
trial: 'A 30-day trial with every paid capability (bank sync, Skatteverket, AI, e-mail) is active from now.',
message:
'Company created and ready for bookkeeping. This connection uses it automatically from the next call. Remaining setup: bank connection, Skatteverket connection, and the first transactions.',
next: {
description: 'Load the onboarding skill for the remaining setup steps (bank, Skatteverket, first transactions).',
tool: 'gnubok_load_skill',
args: { slug: 'onboarding' },
},
}
},
},
{
name: 'gnubok_connect_bank',
title: 'Connect Bank',
description:
'Bank connection status plus the browser link where the user connects a bank (PSD2, BankID consent; must be logged in to Accounted there). Use after gnubok_create_company or when transactions are missing because no bank is connected.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
outputSchema: {
type: 'object',
properties: {
connected: { type: 'boolean' },
connections: { type: 'array', items: { type: 'object' } },
connect_url: { type: 'string' },
instructions: { type: 'string' },
},
required: ['connected', 'connections', 'connect_url', 'instructions'],
},
catalogVisibility: 'search',
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, companyId, _userId, supabase) {
const { data, error } = await supabase
.from('bank_connections')
.select('id, bank_name, status, created_at')
.eq('company_id', companyId)
.in('status', ['pending', 'pending_selection', 'active', 'expired', 'error'])
.order('created_at', { ascending: false })
if (error) throw error
const connections = (data ?? []) as Array<{ id: string; bank_name: string | null; status: string; created_at: string }>
const active = connections.filter((c) => c.status === 'active')
const connectUrl = `${connectLinkBaseUrl()}/import?mode=psd2`
return {
connected: active.length > 0,
connections: connections.map((c) => ({
connection_id: c.id,
bank: c.bank_name,
status: c.status,
since: c.created_at,
})),
connect_url: connectUrl,
instructions:
active.length > 0
? 'At least one bank is connected and syncing. To add another bank, give the user the connect_url.'
: 'Give the user the connect_url to open in their browser (they must be logged in to Accounted there). They pick their bank and approve with BankID; consent lasts up to 180 days and the first transactions arrive within a minute. Tell them to come back here when done, then continue with gnubok_list_uncategorized_transactions.',
}
},
},
{
name: 'gnubok_connect_skatteverket',
title: 'Connect Skatteverket',
description:
'Skatteverket connection status plus the browser link where the user authorises Accounted (BankID as firmatecknare; logged in to Accounted there). Enables skattekonto sync and filing of moms/AGI. Use after gnubok_create_company or when a filing tool reports no connection.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
outputSchema: {
type: 'object',
properties: {
available: { type: 'boolean' },
connected: { type: 'boolean' },
token_expires_at: { type: ['string', 'null'] },
connect_url: { type: ['string', 'null'] },
instructions: { type: 'string' },
},
required: ['available', 'connected', 'token_expires_at', 'connect_url', 'instructions'],
},
catalogVisibility: 'search',
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, companyId, _userId, supabase) {
const enabled = process.env.SKATTEVERKET_ENABLED === 'true'
const { data, error } = await supabase
.from('skatteverket_tokens')
.select('expires_at')
.eq('company_id', companyId)
.order('expires_at', { ascending: false })
.limit(1)
.maybeSingle()
if (error) throw error
const token = data as { expires_at: string } | null
const connected = Boolean(token)
const connectUrl = `${connectLinkBaseUrl()}/api/extensions/ext/skatteverket/authorize?return_to=%2F`
return {
available: enabled,
connected,
token_expires_at: token?.expires_at ?? null,
connect_url: enabled ? connectUrl : null,
instructions: !enabled
? 'The Skatteverket integration is not enabled on this installation. Declarations can still be downloaded as files and filed manually at skatteverket.se.'
: connected
? 'Skatteverket is connected. Skattekonto syncs automatically; momsdeklaration and AGI can be filed from here (each filing stages for approval).'
: 'Give the user the connect_url to open in their browser (logged in to Accounted). Skatteverket asks them to identify with BankID as firmatecknare and approve the access; they land back in Accounted afterwards. Tell them to come back here when done.',
}
},
},
{
name: 'gnubok_get_company_settings',
title: 'Get Company Settings',
@@ -18437,14 +18702,14 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
'',
...(isAnonymous
? [
'NOT CONNECTED YET. Without an account you can call gnubok_search_tools, gnubok_list_skills and gnubok_load_skill. Every other tool needs the user to connect their Accounted account: calling one returns an authentication challenge that your client shows as a Connect prompt. A user who has no account creates one right there (BankID or e-mail, about a minute), and the call is then retried automatically. To start bookkeeping for a company that is not in Accounted yet, call gnubok_list_companies to trigger the connect step, then continue with the setup.',
'NOT CONNECTED YET. Without an account you can call gnubok_search_tools, gnubok_list_skills and gnubok_load_skill. Every other tool needs the user to connect their Accounted account: calling one returns an authentication challenge that your client shows as a Connect prompt. A user who has no account creates one right there (BankID or e-mail, about a minute), and the call is then retried automatically. To set up bookkeeping for a company that is not in Accounted yet, load the "onboarding" skill (gnubok_load_skill) and follow it: gnubok_create_company is the first protected call and triggers the connect step.',
'',
]
: []),
'Discovery:',
'• tools/list returns common tool schemas. Call gnubok_search_tools(query="…") for specialized tools: it ranks all capabilities; pass detail="name"|"summary"|"full" to control payload size.',
'• gnubok_get_agent_briefing returns recommended_tools: ordered per-workflow tool loadouts (categorize_month, close_period, invoice_run, vat_declaration, payroll_month). If your harness defers tool loading, batch-load a whole workflow in one call (e.g. Claude Code ToolSearch select:a,b,c) instead of searching cluster by cluster.',
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId ?? 'none yet: this account has no company; it must be created in the web app before company-data tools work'}); when selecting another company, repeat company_id on every company-data call, including approval.`,
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId ?? 'none yet: this account has no company. Create it with gnubok_create_company (preview first, then confirm=true); the "onboarding" skill walks the whole setup'}); when selecting another company, repeat company_id on every company-data call, including approval.`,
'• MCP resources use the API key default company. For a selected non-default company, call gnubok_get_agent_briefing with company_id instead of relying on Accounted://company/current or other company-data resources.',
'• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first: domain workflows are documented as loadable skills with tool references.',
'• When a tool is missing, a description misled you, a result looks wrong, or something worked unusually well, call gnubok_feedback (context + suggestion, optional tool_name). It is read by the product team and has fixed real bugs; include ids and what you expected. Rate-limited 1/min/key, so batch a session\'s findings into one call.',
@@ -9,6 +9,7 @@ import { bankReconciliationSkill } from './bank-reconciliation'
import { kreditfakturaProcessSkill } from './kreditfaktura-process'
import { customerOnboardingSkill } from './customer-onboarding'
import { reconcileMonthSkill } from './reconcile-month'
import { onboardingSkill } from './onboarding'
import { loadAtomsAsSkills, loadReferenceById } from './atoms'
/** Static workflow skills the server ships with. Tier: 'workflow'. */
@@ -22,6 +23,7 @@ export const workflowSkills: Skill[] = [
kreditfakturaProcessSkill,
customerOnboardingSkill,
reconcileMonthSkill,
onboardingSkill,
]
/** @deprecated Use `workflowSkills` for the static set, or `loadAllSkills(supabase)`
@@ -0,0 +1,125 @@
import type { Skill } from './types'
const body = `# Onboarding: set up a company in Accounted from the conversation
From "my company is not in Accounted yet" to a working ledger without the
user opening the web app first. The only browser steps are the ones that
legally need a human with BankID: connecting (creating the account),
approving the bank consent, and authorising Skatteverket. Everything else
happens here.
## When to use
- "Sätt upp bokföring för mitt AB / min enskilda firma"
- "Jag har precis startat bolag, hur kommer jag igång?"
- "Lägg till ett nytt bolag" (an existing user adding a second company)
- A byrå/consultant onboarding a new client company (pass \`team_id\`)
## Step 0: connect
If this session is not connected yet, the first company-scoped call (for
example \`gnubok_create_company\`) returns an authentication challenge that the
client shows as a Connect prompt. Tell the user: "Klicka på Connect; har du
inget konto skapar du det där (BankID eller e-post), det tar en minut." The
call is retried automatically once connected. Do not send the user to the web
app to sign up first.
## Step 1: gather the facts (ask, never assume)
Collect these before creating anything. The order mirrors the in-app wizard.
1. **Organisationsnummer** (10 digits). Required when the company is
VAT-registered (the momsregistreringsnummer on every invoice derives from
it) and strongly recommended otherwise: it drives Skatteverket/SIE exports. An enskild firma's
org number is the owner's personnummer; that is fine to store here.
2. **Company form**: \`aktiebolag\` or \`enskild_firma\`. Only these two are
supported today; HB/KB/förening are not.
3. **Company name** as registered.
4. **F-skatt**: godkänd för F-skatt? Always ask; the tool refuses to assume it.
A brand-new company may still be waiting for Skatteverket's approval (then
false).
5. **Fiscal year**: for enskild firma always the calendar year (do not ask);
its first year may be shorter or up to 18 months but always ends 31 December.
For an AB ask whether it is the calendar year or another 12-month period
(\`fiscal_year_start_month\`). For a company in its FIRST year ask for the
exact first fiscal year start and end (BFL 3 kap.: it may be shorter than
12 months or up to 18 months) and pass \`first_fiscal_year\`.
6. **VAT**: momsregistrerad? If yes, which period: \`monthly\`, \`quarterly\` or
\`yearly\`. This is required when VAT-registered: without it Accounted
generates no VAT deadlines at all, silently. If the user does not know,
the rule of thumb: turnover under 1 MSEK may report yearly, under 40 MSEK
quarterly, above that monthly; Skatteverket's registration decision states
the actual period. Never guess it into the tool; ask.
7. **Accounting method**: \`accrual\` (faktureringsmetoden) or \`cash\`
(kontantmetoden / bokslutsmetoden). Cash is only allowed under 3 MSEK
turnover and is common for small enskild firma; AB with invoices usually
run accrual.
## Step 2: preview, confirm, create
Call \`gnubok_create_company\` WITHOUT \`confirm\` first. Read the preview back
to the user in plain Swedish: company form, org number, the fiscal period
dates, VAT setup (registered + period), method. Only after an explicit "ja"
call it again with the same arguments and \`confirm: true\`.
What creation does in one step: company + owner membership, BAS chart of
accounts for the company form, settings, the first fiscal period, and the
automatic tax deadlines (moms, F-skatt, AGI, inkomstdeklaration). The 30-day
trial with bank sync, Skatteverket, AI and e-mail starts immediately. This
connection uses the new company automatically from the next call; no
re-authentication.
## Step 3: connect the bank
Call \`gnubok_connect_bank\`. It reports existing connections and returns a
\`connect_url\`. The user opens it in a browser where they are logged in to
Accounted, picks the bank and approves with BankID (PSD2 consent, up to 180
days). Transactions start syncing within a minute. If they prefer not to
connect a bank, they can import bank statements as files in the web app
instead; do not block on this step.
## Step 4: connect Skatteverket (optional but recommended)
Call \`gnubok_connect_skatteverket\`. Same pattern: the user opens the
\`connect_url\`, identifies with BankID as firmatecknare at Skatteverket, and
lands back in Accounted. This enables skattekonto sync and filing of
momsdeklaration and arbetsgivardeklaration from here. Filing is never
mandatory through Accounted: every declaration can be downloaded and filed
manually.
## Step 5: first bookkeeping
Once transactions arrive: \`gnubok_list_uncategorized_transactions\` and the
categorize flow (\`gnubok_suggest_categories\`, \`gnubok_categorize_transaction\`,
approval). For a company with history in another system, offer the SIE
import (\`gnubok_import_sie\` in the search catalog) before categorizing.
## Tools
- \`gnubok_create_company\`: preview (no confirm) then create (confirm=true)
- \`gnubok_list_companies\`: see which companies this connection can reach
- \`gnubok_connect_bank\`: status + connect link for PSD2 bank consent
- \`gnubok_connect_skatteverket\`: status + connect link for Skatteverket
- \`gnubok_get_agent_briefing\`: the company's settings and state once created
- \`gnubok_list_uncategorized_transactions\`: the first real bookkeeping step
## Pitfalls
- A VAT-registered company without a moms period is refused on purpose; do
not work around it by claiming the company is not VAT-registered.
- Do not create a company twice on a retry: check \`gnubok_list_companies\`
if a create call was interrupted.
- Bookkeeping duty starts when the company exists in Accounted with a fiscal
period. Never create a company "to try things out" for a real
organisation; use the sandbox in the web app for demos.
`
export const onboardingSkill: Skill = {
slug: 'onboarding',
name: 'Onboarding: New Company Setup',
summary:
'Set up a company from the conversation: gather facts, preview and create with gnubok_create_company, then hand out the bank and Skatteverket connect links.',
tags: ['onboarding', 'setup', 'company', 'bank', 'skatteverket', 'agent-first'],
body,
tier: 'workflow',
}