diff --git a/DECISIONS.md b/DECISIONS.md index 7edb5f05..17500843 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1289,5 +1289,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-26] OAuth consent pre-checks ALL scopes (one-click, list collapsed in details): founder call after the read-only default dead-ended agent flows; defensible because every write is staged for approval, rows stay untickable, grant revocable. [2026-08-26] accounting_method optional with form default (AB=accrual, EF=cash) in CompanySetupSchema/planCompanySetup: founder call to cut agent onboarding input to orgnr + moms period; the default is flagged (accounting_method_defaulted) and must be read back in the preview, never silent. [2026-08-26] Removed the 1580 entry from ACCOUNT_DESCRIPTIONS and flipped AccountNumber name precedence to DB-name-first: the entry falsely labeled 1580 'Fordran for skatt' (tax receivables are 1640/1650; 1580 was traditionally card/coupon receivables, moved by BAS to 1686), and the hardcoded name silently overrode users' own kontoplan names. No replacement entry: 1580 is deliberately off-catalog, so companies with a legacy 1580 now see their own account name. +[2026-08-27] generateOpenApiSpec now emits the registry's `example` as an OpenAPI media-type `example` on JSON request bodies and JSON 200 responses, and the portable renderOperationMd (skills/openapi-to-skill) renders it as a fenced json block. EndpointDefinition.example has been REQUIRED since Phase 1 and all 125 endpoints populate example.response, but the generator never touched it, so the worked examples reached only the docs markdown builder (lib/docs/content/reference.ts): /api/v1/openapi.json carried zero examples and the generated skills/accounted-api had 0 json blocks across all 12 reference files. Chosen over adding a new registry field (the data already existed, only the delivery was missing) and over hand-writing examples into the overlays (they would drift from the schemas). Examples attach to `application/json` only: a multipart body's example is a file part JSON cannot express, and a binary (application/pdf) response's registry example describes the JSON envelope that endpoint does not send, so attaching it there would be a lie (pinned by openapi-examples.test.ts). SKILL.md is byte-identical: the 3304 added lines land in the on-demand references/*.md, not the always-loaded entry file. Rationale is Anthropic's tool-use-examples finding (72% to 90% on complex parameter handling): a condensed schema states a field's shape, an example states the conventions the shape cannot express. POST fiscal-periods/:id/currency-revaluation gained the one missing example.request so the exhaustive coverage assertions hold. +[2026-08-27] /api/v1 now emits `Retry-After` on a 429 (RATE_LIMITED), sourced from a new exported `RATE_LIMIT_RETRY_AFTER_SECONDS` in lib/auth/api-keys.ts that the MCP server's previously hardcoded '60' also consumes. skills/accounted-api and its overlays (quickstart.md, conventions.md) have told agents to "honor Retry-After" on a 429 since the skill shipped, but no v1 route ever sent one: the wrapper's auth failure path early-returns through v1ErrorResponseFromCode, whose finalize() set only X-Request-Id and Gnubok-Version. 60 is an exact upper bound, not a guess: validate_and_increment_api_key uses a FIXED one-minute tumbling window and the limited branch deliberately does not slide it, so the current window can never have more than 60 s left. Chosen over emitting the IETF `RateLimit` / `RateLimit-Policy` fields (draft-ietf-httpapi-ratelimit-headers-11, 2026-05-23), which is the correct destination but needs a migration first: the RPC computes remaining quota and the exact reset instant internally yet its RETURNS TABLE carries no count/limit/window column, so TypeScript cannot see them. Also corrected the withApiV1 doc comment, which claimed step 8 stamps `X-RateLimit-Limit`; stampHeaders never did, and error paths bypass stampHeaders entirely. NOT fixed here and worth its own change: those same error paths also skip WRAPPED_RESPONSE_HEADERS, so v1 401/403/404/429/500 bodies ship without nosniff/CSP/HSTS. +[2026-08-27] The tools/list payload guard now measures BOTH tool namespaces and asserts on the larger, and the ceiling moves 63.4K to 63.6K to cover it. `?tool_namespace=accounted` rewrites every gnubok_* reference to accounted_* (+3 chars each), costing ~209 tokens across the default catalog, so the accounted projection measured 63 491 tokens against a 63 400 ceiling: already ~90 tokens over, and untested, because the guard only ever serialized the gnubok projection (63 282, which passed). CLAUDE.md points new MCP installs at accounted-mcp and the accounted_* aliases, so the untested projection is the one a new user's client actually receives. The bump buys no new catalog surface: it re-points an existing ceiling at the real worst case. Verified the guard bites by temporarily asserting 63 450 and watching it fail on 63 491. A second test pins the DIRECTION of the delta (accounted > gnubok) so Math.max cannot silently stop describing reality if a future rename flips it. Not chosen: raising the ceiling without measuring both (leaves the same blind spot) or shrinking the catalog to fit 63.4K (a real option, but it is the Code Mode conversation, not a test fix). [2026-08-27] Cockpit auto-landing gated to byrå owner/admin (isCockpitLandingRole; landing route + '/' bounce), superseding the 2026-08-05 all-members widening: plain members land like regular users and open the cockpit from the nav; the middleware zero-company steer stays ungated because a member with no client companies has nowhere else to land. Allowlist over role!=='member' so future roles default to the regular landing. [2026-08-27] Klarmarkera (markPeriodClosedExternally) gets an undo, reopenExternallyClosedPeriod, allowed only while the closed state still comes from klarmarkera (closed_externally set, no closing entry): that close was a person's control decision without a bokslutsverifikat, so reversing it strands nothing, whereas a closePeriod close keeps its closing entry and stays irreversible here. The reopen clears the lock too, because the reason to reopen is to change the period's contents (Forsslund Systems 2026-08-27: five imported years klarmarkerade, then the prior-year SIE turned out wrong; replace refused the closed year, unlock refused the closed state, no way back). Audit_log row plus period.unlocked event; the MCP staged-op surface (lock/unlock) does not get a reopen op yet, follow-up. diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts index a62e0bd2..542ad84d 100644 --- a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts +++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts @@ -49,6 +49,7 @@ registerEndpoint({ 'as_of_date defaults to period_end if omitted.', ], example: { + request: { as_of_date: '2026-12-31' }, response: { data: { operation_id: '0e9c…', type: 'fiscal_periods.currency_revaluation', status: 'succeeded', poll_url: '/api/v1/operations/0e9c…', webhook_event: 'operation.completed' }, meta: { request_id: 'req_…', api_version: '2026-05-12' }, diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index 8ad32f88..7bad2616 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -1,26 +1,47 @@ import { describe, it, expect } from 'vitest' import { tools, deriveToolMeta, isDefaultCatalogTool } from '../server' import { projectToolInputSchema } from '../company-routing' +import { projectToolReferences } from '../tool-namespace' + +// Mirror the real tools/list serializer, including the derived staging _meta +// (requires_approval / approve_tool / preflight) merged over any literal +// _meta: otherwise the guard under-measures the wire payload. +const canonicalToolNames = new Set(tools.map((t) => t.name)) + +function serializeCatalog(namespace: 'gnubok' | 'accounted'): string { + const projection = tools.filter(isDefaultCatalogTool).map((t) => { + const meta = { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) } + const projected = { + name: t.name, + ...(t.title ? { title: t.title } : {}), + description: t.description, + inputSchema: projectToolInputSchema(t), + ...(t.outputSchema ? { outputSchema: t.outputSchema } : {}), + annotations: t.annotations, + ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}), + } + return namespace === 'accounted' + ? projectToolReferences(projected, namespace, canonicalToolNames) + : projected + }) + return JSON.stringify({ tools: projection }) +} + +const tokensFor = (namespace: 'gnubok' | 'accounted') => + Math.round(serializeCatalog(namespace).length / 4) describe('tools/list payload size guard', () => { it('keeps the projected tools/list payload under the context-budget ceiling', () => { - // Mirror the real tools/list serializer, including the derived staging - // _meta (requires_approval / approve_tool / preflight) merged over any - // literal _meta: otherwise the guard under-measures the wire payload. - const projection = tools.filter(isDefaultCatalogTool).map((t) => { - const meta = { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) } - return { - name: t.name, - ...(t.title ? { title: t.title } : {}), - description: t.description, - inputSchema: projectToolInputSchema(t), - ...(t.outputSchema ? { outputSchema: t.outputSchema } : {}), - annotations: t.annotations, - ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}), - } - }) - const payload = JSON.stringify({ tools: projection }) - const approxTokens = Math.round(payload.length / 4) + // Measure BOTH namespaces and guard the larger. + // + // `?tool_namespace=accounted` rewrites every gnubok_* reference to + // accounted_* (+3 chars each), so the accounted projection is inherently + // ~200 tokens larger than the gnubok one. CLAUDE.md points new MCP + // installs at the accounted-mcp package and the accounted_* aliases, so + // that larger payload is what a NEW user's client actually receives, + // while this guard measured only the legacy namespace and let the real + // worst case drift untested. + const approxTokens = Math.max(tokensFor('gnubok'), tokensFor('accounted')) // Ceiling progression: 20K to 25K to 30K to 31K to 31.5K to 32K to 36K. // * 20K → 25K when item 8 of the agent-native API plan landed // (additionalProperties: false on all inputSchemas + period_status in the @@ -242,9 +263,25 @@ describe('tools/list payload size guard', () => { // catalog: the onboarding efterkontroll instructs matching bank rows // against SIE verifikat, and a search-only tool is uncallable on // Claude.ai (E2E #12 punted to the web app over it). + // * 63.4K to 63.6K with NO new tool: the guard started measuring the + // accounted_* namespace as well as gnubok_* and asserting on the + // larger. The accounted projection was already ~90 tokens over the + // 63.4K line (the namespace rewrite costs ~209 tokens on its own) and + // nothing tested it, so this bump buys no new catalog surface. It + // re-points an existing ceiling at the payload new installs actually + // receive; the gnubok projection still sits ~320 tokens under it. // 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(63_400) + expect(approxTokens).toBeLessThan(63_600) + }) + + it('keeps the accounted_* namespace as the measured worst case', () => { + // Pins the DIRECTION of the delta, not its size. If a future change ever + // made gnubok_* the larger projection, the Math.max above would silently + // keep passing while this guard stopped describing reality. + expect(serializeCatalog('accounted').length).toBeGreaterThan( + serializeCatalog('gnubok').length, + ) }) }) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index a5ef505e..24b99caa 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -12,6 +12,7 @@ import { validateApiKey, createServiceClientNoCookies, hasScope, + RATE_LIMIT_RETRY_AFTER_SECONDS, TOOL_SCOPE_MAP, type ApiKeyMode, type ApiKeyScope, @@ -19384,7 +19385,10 @@ export async function handleMcpRequest(request: Request): Promise { if (status === 429) { return new Response(authResult.error, { status: 429, - headers: { 'Content-Type': 'text/plain', 'Retry-After': '60' }, + headers: { + 'Content-Type': 'text/plain', + 'Retry-After': String(RATE_LIMIT_RETRY_AFTER_SECONDS), + }, }) } return unauthorized() diff --git a/lib/api/v1/__tests__/openapi-examples.test.ts b/lib/api/v1/__tests__/openapi-examples.test.ts new file mode 100644 index 00000000..0a650073 --- /dev/null +++ b/lib/api/v1/__tests__/openapi-examples.test.ts @@ -0,0 +1,100 @@ +/** + * The registry's worked `example` must reach the OpenAPI spec. + * + * `EndpointDefinition.example` has always been required, and every endpoint + * populates `example.response`, but `generateOpenApiSpec()` never emitted it. + * The examples therefore reached only the docs markdown builder + * (lib/docs/content/reference.ts); the spec carried none, so spec consumers + * (skills/accounted-api, client generators, any agent reading + * /api/v1/openapi.json) saw schemas without a single concrete body. + * + * A condensed schema states the shape of a field. An example states the + * conventions the shape cannot express, which is the half agents get wrong. + */ + +import { describe, expect, it } from 'vitest' +import { generateOpenApiSpec, listEndpoints } from '../registry' +// Side-effect import: populates the ENDPOINTS registry from every route file. +import '../load-routes' + +type MediaType = { schema?: unknown; example?: unknown } +type OperationObject = { + requestBody?: { content: Record } + responses: Record }> +} + +const spec = generateOpenApiSpec('https://unit.test') + +function operation(path: string, method: string): OperationObject { + const op = (spec.paths[path] as Record | undefined)?.[method] + expect(op, `${method.toUpperCase()} ${path} missing from spec`).toBeDefined() + return op as OperationObject +} + +describe('generateOpenApiSpec examples', () => { + it('attaches the registry response example to the JSON success media type', () => { + const op = operation('/api/v1/companies/{companyId}/customers', 'post') + const example = op.responses['200']?.content?.['application/json']?.example as + | { data?: unknown } + | undefined + expect(example).toBeDefined() + expect(example).toHaveProperty('data') + }) + + it('attaches the registry request example to the JSON request body', () => { + const op = operation('/api/v1/companies/{companyId}/customers', 'post') + const example = op.requestBody?.content['application/json']?.example as + | Record + | undefined + expect(example).toBeDefined() + expect(example).toHaveProperty('name') + }) + + it('emits a response example on every JSON success response', () => { + const missing: string[] = [] + for (const [path, item] of Object.entries(spec.paths)) { + for (const [method, op] of Object.entries(item as Record)) { + const json = op.responses['200']?.content?.['application/json'] + // 204 endpoints and binary (application/pdf) responses carry no JSON body. + if (!json) continue + if (json.example === undefined) missing.push(`${method.toUpperCase()} ${path}`) + } + } + expect(missing).toEqual([]) + }) + + it('emits a request example on every JSON request body', () => { + // `example.request` is optional on EndpointDefinition, but a registered + // JSON body with no worked example is the gap this test exists to hold + // shut: an agent reading the spec would get a shape and no conventions. + const missing: string[] = [] + for (const [path, item] of Object.entries(spec.paths)) { + for (const [method, op] of Object.entries(item as Record)) { + const json = op.requestBody?.content['application/json'] + if (!json) continue + if (json.example === undefined) missing.push(`${method.toUpperCase()} ${path}`) + } + } + expect(missing).toEqual([]) + }) + + it('does not attach a JSON example to a binary response', () => { + // A PDF endpoint's registry example describes the JSON envelope it does + // not send; attaching it to the binary media type would be a lie. + for (const [path, item] of Object.entries(spec.paths)) { + for (const [method, op] of Object.entries(item as Record)) { + for (const [contentType, media] of Object.entries(op.responses['200']?.content ?? {})) { + if (contentType === 'application/json') continue + expect(media.example, `${method.toUpperCase()} ${path} ${contentType}`).toBeUndefined() + } + } + } + }) + + it('keeps every registered endpoint carrying a response example in the registry', () => { + const missing = listEndpoints() + .filter((def) => !def.example?.response) + .map((def) => def.operation) + expect(missing).toEqual([]) + }) +}) diff --git a/lib/api/v1/__tests__/with-api-v1.test.ts b/lib/api/v1/__tests__/with-api-v1.test.ts index 5b64cab3..b99d4f49 100644 --- a/lib/api/v1/__tests__/with-api-v1.test.ts +++ b/lib/api/v1/__tests__/with-api-v1.test.ts @@ -47,7 +47,11 @@ vi.mock('@/lib/api/idempotency', async () => { } }) -import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { + validateApiKey, + createServiceClientNoCookies, + RATE_LIMIT_RETRY_AFTER_SECONDS, +} from '@/lib/auth/api-keys' import { checkIdempotencyKey, storeIdempotencyResponse, @@ -145,6 +149,27 @@ describe('withApiV1: auth', () => { expect(res.status).toBe(429) const body = await res.json() expect(body.error.code).toBe('RATE_LIMITED') + // The published skill instructs agents to honor Retry-After on a 429. + // Before this header existed that instruction pointed at nothing, so an + // unattended client had to back off blindly. + expect(res.headers.get('Retry-After')).toBe(String(RATE_LIMIT_RETRY_AFTER_SECONDS)) + }) + + it('does not advertise Retry-After on a non-throttle error', async () => { + mockValidate.mockResolvedValue({ error: 'Invalid API key', status: 401 }) + + const handler = withApiV1('companies.list', async (_req, ctx) => + ok({ ok: true }, { requestId: ctx.requestId }), + ) + + const res = await handler( + makeRequest('https://x.test/api/v1/companies', { + headers: { Authorization: 'Bearer gnubok_sk_invalid' }, + }), + emptyParams(), + ) + expect(res.status).toBe(401) + expect(res.headers.get('Retry-After')).toBeNull() }) }) diff --git a/lib/api/v1/errors.ts b/lib/api/v1/errors.ts index eec119dd..151eb6fe 100644 --- a/lib/api/v1/errors.ts +++ b/lib/api/v1/errors.ts @@ -57,6 +57,11 @@ export interface V1ErrorContext { status?: number /** Agent-actionable next-step suggestions: { unlock_endpoint, next_open_period }. */ validAlternatives?: Record + /** + * Seconds to advertise in `Retry-After`. Set on retryable throttles so an + * unattended client can pace itself instead of backing off blindly. + */ + retryAfterSeconds?: number } function docsUrlFor(code: string): string { @@ -119,6 +124,12 @@ async function rewriteEnvelope( function finalize(res: NextResponse, ctx: V1ErrorContext): NextResponse { res.headers.set('X-Request-Id', ctx.requestId) res.headers.set(API_V1_VERSION_HEADER, API_V1_VERSION) + // The published skill tells agents to honor Retry-After on a 429. Until + // this landed, /api/v1 never sent one, so that instruction pointed at a + // header that did not exist. + if (ctx.retryAfterSeconds !== undefined) { + res.headers.set('Retry-After', String(ctx.retryAfterSeconds)) + } return res } diff --git a/lib/api/v1/registry.ts b/lib/api/v1/registry.ts index ba5c6b56..6b446fb2 100644 --- a/lib/api/v1/registry.ts +++ b/lib/api/v1/registry.ts @@ -383,9 +383,21 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec { // Binary responses (e.g. application/pdf) declare a `format: binary` // schema rather than deriving from the Zod success type. + // The registry's worked `example` travels with the schema as an OpenAPI + // media-type `example`. Without this the examples reached only the docs + // markdown builder (lib/docs/content/reference.ts): the spec itself carried + // none, so neither /api/v1/openapi.json consumers nor the generated + // skills/accounted-api ever saw a concrete request or response body. + // Attached to JSON media types only: a binary response (application/pdf) + // has no meaningful JSON example to show. const successContent = def.response.contentType && def.response.contentType !== 'application/json' ? { [def.response.contentType]: { schema: { type: 'string', format: 'binary' } } } - : { 'application/json': { schema: zodToJsonSchema(def.response.success) } } + : { + 'application/json': { + schema: zodToJsonSchema(def.response.success), + example: def.example.response, + }, + } // 204 No Content endpoints (DELETEs returning noContent()) carry no body: // emit a bare 204 instead of a 200 { data, meta } so the spec stops @@ -428,7 +440,16 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec { } requestBody = { required: true, - content: { [contentType]: { schema: bodySchema } }, + content: { + [contentType]: { + schema: bodySchema, + // Only JSON bodies carry a worked example; a multipart upload's + // example would be a file part, which JSON cannot express. + ...(def.example.request && contentType === 'application/json' + ? { example: def.example.request } + : {}), + }, + }, } } diff --git a/lib/api/v1/with-api-v1.ts b/lib/api/v1/with-api-v1.ts index 8e36dc5a..24f91208 100644 --- a/lib/api/v1/with-api-v1.ts +++ b/lib/api/v1/with-api-v1.ts @@ -20,8 +20,8 @@ * never cached, so a simulation can never be replayed in place of the * real write that follows it. * 7. Invokes the handler with a typed RouteContext. - * 8. Stamps `X-Request-Id`, `Gnubok-Version`, `X-RateLimit-Limit` on the - * response. + * 8. Stamps `X-Request-Id` and `Gnubok-Version` on the response, plus + * `Retry-After` on a 429. * 9. Catches any thrown value and converts it to the v1 error envelope via * `v1ErrorResponse`. * @@ -45,6 +45,7 @@ import { createServiceClientNoCookies, extractBearerToken, hasScope, + RATE_LIMIT_RETRY_AFTER_SECONDS, validateApiKey, } from '@/lib/auth/api-keys' @@ -333,8 +334,13 @@ export function withApiV1

…", + "xml_filename": "AGI_5566778899_202605.xml" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `PATCH /api/v1/companies/{companyId}/salary-runs/{id}/lines/{lineId}` @@ -747,6 +1047,13 @@ Request body: } ``` +Example request: +```json +{ + "amount": 5500 +} +``` + Response `200`: ```ts { @@ -776,6 +1083,20 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "salary_line_item_id": "sli_31c9…", + "amount": 5500 + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `DELETE /api/v1/companies/{companyId}/salary-runs/{id}/lines/{lineId}` @@ -835,6 +1156,21 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "id": "run_a8f1…", + "status": "paid", + "paid_at": "2026-05-25T08:00:00Z" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `GET /api/v1/companies/{companyId}/salary-runs/{id}/payslips/{employeeId}/pdf` diff --git a/skills/accounted-api/references/suppliers.md b/skills/accounted-api/references/suppliers.md index edd1fffd..c96b5255 100644 --- a/skills/accounted-api/references/suppliers.md +++ b/skills/accounted-api/references/suppliers.md @@ -42,6 +42,38 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": [ + { + "id": "0e9c…", + "supplier_id": "a8f1…", + "supplier_name": "Office Depot AB", + "arrival_number": 42, + "supplier_invoice_number": "2026-1234", + "invoice_date": "2026-05-10", + "due_date": "2026-06-09", + "status": "registered", + "currency": "SEK", + "subtotal": 1000, + "vat_amount": 250, + "total": 1250, + "paid_amount": 0, + "remaining_amount": 1250, + "is_credit_note": false, + "paid_at": null, + "created_at": "2026-05-13T15:00:00Z" + } + ], + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12", + "next_cursor": null + } +} +``` + --- ### `POST /api/v1/companies/{companyId}/supplier-invoices` @@ -92,6 +124,27 @@ Request body: } ``` +Example request: +```json +{ + "supplier_id": "a8f1…", + "supplier_invoice_number": "2026-1234", + "invoice_date": "2026-05-10", + "due_date": "2026-06-09", + "default_dimensions": { + "6": "P001" + }, + "items": [ + { + "description": "Office supplies", + "amount": 1000, + "account_number": "5410", + "vat_rate": 0.25 + } + ] +} +``` + Response `200`: ```ts { @@ -122,6 +175,25 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "id": "0e9c…", + "supplier_id": "a8f1…", + "arrival_number": 42, + "supplier_invoice_number": "2026-1234", + "status": "registered", + "total": 1250, + "registration_journal_entry_id": "7b3a…" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `GET /api/v1/companies/{companyId}/supplier-invoices/{id}` @@ -183,6 +255,29 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "id": "0e9c…", + "supplier_id": "a8f1…", + "arrival_number": 42, + "supplier_invoice_number": "2026-1234", + "status": "registered", + "currency": "SEK", + "subtotal": 1000, + "vat_amount": 250, + "total": 1250, + "remaining_amount": 1250, + "is_credit_note": false + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `PATCH /api/v1/companies/{companyId}/supplier-invoices/{id}` @@ -217,6 +312,13 @@ Request body: } ``` +Example request: +```json +{ + "payment_reference": "OCR-1234567890" +} +``` + Response `200`: ```ts { @@ -257,6 +359,20 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "id": "0e9c…", + "payment_reference": "OCR-1234567890" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `POST /api/v1/companies/{companyId}/supplier-invoices/{id}/approve` @@ -298,6 +414,22 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "id": "0e9c…", + "status": "approved", + "arrival_number": 42, + "supplier_invoice_number": "2026-1234" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `POST /api/v1/companies/{companyId}/supplier-invoices/{id}/credit` @@ -341,6 +473,23 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "credit_note_id": "4d2a…", + "original_id": "0e9c…", + "arrival_number": 43, + "supplier_invoice_number": "KREDIT-2026-1234", + "registration_journal_entry_id": "9c2f…" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `POST /api/v1/companies/{companyId}/supplier-invoices/{id}/mark-paid` @@ -378,6 +527,13 @@ Request body: } ``` +Example request: +```json +{ + "payment_date": "2026-05-13" +} +``` + Response `200`: ```ts { @@ -400,6 +556,25 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "id": "0e9c…", + "status": "paid", + "total": 1250, + "paid_amount": 1250, + "remaining_amount": 0, + "paid_at": "2026-05-13", + "payment_journal_entry_id": "7b3a…" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `GET /api/v1/companies/{companyId}/suppliers` @@ -435,6 +610,31 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": [ + { + "id": "a8f1…", + "name": "Office Depot AB", + "supplier_type": "swedish_business", + "email": "invoices@officedepot.example", + "org_number": "556677-8899", + "vat_number": "SE556677889901", + "default_payment_terms": 30, + "default_currency": "SEK", + "archived_at": null, + "created_at": "2026-04-12T08:30:00Z" + } + ], + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12", + "next_cursor": null + } +} +``` + --- ### `POST /api/v1/companies/{companyId}/suppliers` @@ -485,6 +685,20 @@ Request body: } ``` +Example request: +```json +{ + "name": "Office Depot AB", + "supplier_type": "swedish_business", + "email": "invoices@officedepot.example", + "org_number": "556677-8899", + "bankgiro": "123-4567", + "default_expense_account": "5410", + "default_payment_terms": 30, + "default_currency": "SEK" +} +``` + Response `200`: ```ts { @@ -524,6 +738,30 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "id": "0e9c…", + "name": "Office Depot AB", + "supplier_type": "swedish_business", + "email": "invoices@officedepot.example", + "org_number": "556677-8899", + "bankgiro": "123-4567", + "default_expense_account": "5410", + "default_payment_terms": 30, + "default_currency": "SEK", + "archived_at": null, + "created_at": "2026-05-13T15:00:00Z", + "updated_at": "2026-05-13T15:00:00Z" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `GET /api/v1/companies/{companyId}/suppliers/{id}` @@ -584,6 +822,30 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "id": "a8f1…", + "name": "Office Depot AB", + "supplier_type": "swedish_business", + "email": "invoices@officedepot.example", + "org_number": "556677-8899", + "bankgiro": "123-4567", + "default_expense_account": "5410", + "default_payment_terms": 30, + "default_currency": "SEK", + "archived_at": null, + "created_at": "2026-04-12T08:30:00Z", + "updated_at": "2026-04-30T11:22:09Z" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `PATCH /api/v1/companies/{companyId}/suppliers/{id}` @@ -634,6 +896,14 @@ Request body: } ``` +Example request: +```json +{ + "default_payment_terms": 14, + "notes": "New payment terms agreed 2026-05-12." +} +``` + Response `200`: ```ts { @@ -673,6 +943,22 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "id": "0e9c…", + "name": "Office Depot AB", + "default_payment_terms": 14, + "notes": "New payment terms agreed 2026-05-12." + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `DELETE /api/v1/companies/{companyId}/suppliers/{id}` @@ -727,6 +1013,24 @@ Request body: } ``` +Example request: +```json +{ + "suppliers": [ + { + "name": "Office Depot AB", + "supplier_type": "swedish_business", + "org_number": "556677-8899" + }, + { + "name": "Cloud Hosting GmbH", + "supplier_type": "eu_business", + "vat_number": "DE123456789" + } + ] +} +``` + Response `200`: ```ts { @@ -743,3 +1047,38 @@ Response `200`: } } ``` + +Example response `200`: +```json +{ + "data": { + "results": [ + { + "ok": true, + "request_index": 0, + "data": { + "id": "0e9c…", + "name": "Office Depot AB" + } + }, + { + "ok": true, + "request_index": 1, + "data": { + "id": "4d2a…", + "name": "Cloud Hosting GmbH" + } + } + ], + "summary": { + "total": 2, + "succeeded": 2, + "failed": 0 + } + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` diff --git a/skills/accounted-api/references/webhooks.md b/skills/accounted-api/references/webhooks.md index f41b1c3f..c7eac34d 100644 --- a/skills/accounted-api/references/webhooks.md +++ b/skills/accounted-api/references/webhooks.md @@ -40,6 +40,31 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "webhooks": [ + { + "id": "a8f1…", + "name": "CRM sync", + "event_type": "invoice.paid", + "webhook_url": "https://example.com/hooks/gnubok", + "active": true, + "api_version_pinned": "2026-05-12", + "disabled_at": null, + "disabled_reason": null, + "created_at": "2026-05-15T12:00:00Z" + } + ] + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `POST /api/v1/companies/{companyId}/webhooks` @@ -71,6 +96,15 @@ Request body: } ``` +Example request: +```json +{ + "event_type": "invoice.paid", + "webhook_url": "https://example.com/hooks/gnubok", + "name": "CRM sync" +} +``` + Response `200`: ```ts { @@ -97,6 +131,29 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "id": "a8f1…", + "name": "CRM sync", + "event_type": "invoice.paid", + "webhook_url": "https://example.com/hooks/gnubok", + "active": true, + "api_version_pinned": "2026-05-12", + "disabled_at": null, + "disabled_reason": null, + "secret": "whsec_…", + "description": null, + "created_at": "2026-05-15T12:00:00Z" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `GET /api/v1/companies/{companyId}/webhooks/{id}` @@ -140,6 +197,29 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "id": "a8f1…", + "name": "CRM sync", + "description": null, + "event_type": "invoice.paid", + "webhook_url": "https://example.com/hooks/gnubok", + "active": true, + "api_version_pinned": "2026-05-12", + "disabled_at": null, + "disabled_reason": null, + "created_at": "2026-05-15T12:00:00Z", + "updated_at": "2026-05-15T12:00:00Z" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `PATCH /api/v1/companies/{companyId}/webhooks/{id}` @@ -165,6 +245,13 @@ Request body: { name?: string, description?: string, webhook_url?: string, active?: boolean } ``` +Example request: +```json +{ + "active": true +} +``` + Response `200`: ```ts { @@ -191,6 +278,29 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "id": "a8f1…", + "name": "CRM sync", + "description": null, + "event_type": "invoice.paid", + "webhook_url": "https://example.com/hooks/gnubok", + "active": true, + "api_version_pinned": "2026-05-12", + "disabled_at": null, + "disabled_reason": null, + "created_at": "2026-05-15T12:00:00Z", + "updated_at": "2026-05-15T12:05:00Z" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `DELETE /api/v1/companies/{companyId}/webhooks/{id}` @@ -248,6 +358,33 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": [ + { + "id": "wh_dlv_…", + "webhook_id": "a8f1…", + "event_type": "invoice.paid", + "status": "delivered", + "attempts": 1, + "next_attempt_at": "2026-05-15T12:00:00Z", + "response_status": 200, + "response_body": "ok", + "error": null, + "request_id": "whdel_…", + "created_at": "2026-05-15T12:00:00Z", + "delivered_at": "2026-05-15T12:00:01Z" + } + ], + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12", + "next_cursor": null + } +} +``` + --- ### `POST /api/v1/companies/{companyId}/webhooks/{id}/rotate-secret` @@ -283,6 +420,21 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "id": "a8f1…", + "secret": "whsec_…", + "rotated_at": "2026-05-15T12:00:00Z" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `POST /api/v1/companies/{companyId}/webhooks/{id}/test` @@ -317,6 +469,20 @@ Response `200`: } ``` +Example response `200`: +```json +{ + "data": { + "webhook_delivery_id": "wh_dlv_…", + "status": "pending" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` + --- ### `POST /api/v1/webhook-deliveries/{id}/retry` @@ -349,3 +515,17 @@ Response `200`: } } ``` + +Example response `200`: +```json +{ + "data": { + "webhook_delivery_id": "wh_dlv_NEW", + "status": "pending" + }, + "meta": { + "request_id": "req_…", + "api_version": "2026-05-12" + } +} +``` diff --git a/skills/openapi-to-skill/scripts/openapi-inventory.mjs b/skills/openapi-to-skill/scripts/openapi-inventory.mjs index 0f3e816b..5e404a52 100644 --- a/skills/openapi-to-skill/scripts/openapi-inventory.mjs +++ b/skills/openapi-to-skill/scripts/openapi-inventory.mjs @@ -295,6 +295,26 @@ function successResponse(op) { return [null, null] } +/** + * A media-type `example` rendered as a fenced JSON block. + * + * A condensed schema tells an agent the shape of a field; a worked example + * tells it the conventions the shape cannot express (id formats, which + * optional fields normally travel together, plausible values). Both are + * cheap to read and only one of them is derivable from types. + */ +function renderExample(label, value) { + if (value === undefined || value === null) return [] + let json + try { + json = JSON.stringify(value, null, 2) + } catch { + return [] + } + if (!json) return [] + return [`${label}:`, '```json', json, '```', ''] +} + /** * Full Markdown block for one operation: what a reference file is built from. */ @@ -328,7 +348,8 @@ export function renderOperationMd(spec, entry) { lines.push('') } - const body = op.requestBody?.content?.['application/json']?.schema + const jsonBody = op.requestBody?.content?.['application/json'] + const body = jsonBody?.schema const multipart = op.requestBody?.content?.['multipart/form-data']?.schema if (body) { lines.push('Request body:') @@ -336,6 +357,7 @@ export function renderOperationMd(spec, entry) { lines.push(condenseSchema(spec, body)) lines.push('```') lines.push('') + lines.push(...renderExample('Example request', jsonBody.example)) } else if (multipart) { lines.push('Request body (`multipart/form-data`):') lines.push('```ts') @@ -345,13 +367,15 @@ export function renderOperationMd(spec, entry) { } const [code, response] = successResponse(op) - const responseSchema = response?.content?.['application/json']?.schema + const jsonResponse = response?.content?.['application/json'] + const responseSchema = jsonResponse?.schema if (responseSchema) { lines.push(`Response \`${code}\`:`) lines.push('```ts') lines.push(condenseSchema(spec, responseSchema)) lines.push('```') lines.push('') + lines.push(...renderExample(`Example response \`${code}\``, jsonResponse.example)) } else if (response) { const contentTypes = Object.keys(response.content ?? {}) lines.push(