openapi: 3.1.0 info: title: NodeOS Public API version: "1.24.0" description: | Public REST API for external AI agents and development tools (KIRO, Cursor, Codex, Claude Code, OpenAI Agents). ## Quickstart ```bash curl -H "Authorization: Bearer nw_live_..." \ https://nodeos.dk/api/public/v1/projects ``` * **Base URL:** `https://nodeos.dk` — use the apex domain, NOT `www.nodeos.dk`. The `www.` host 302-redirects to apex and most HTTP clients (curl, fetch, axios) strip the `Authorization` header on cross-host redirects (RFC 9110), so the request lands unauthenticated. * **Auth header:** `Authorization: Bearer nw_live_...` * **Versioning:** every response includes `X-API-Version: v1` and `X-NodeOS-Version: `. Breaking changes will ship under a new `/v2` prefix; additive changes remain on `/v1`. * **Scope model:** an API key is scoped to ONE customer/project (organization). Optionally narrowed to specific areas under that customer. See `GET /api/public/v1/me` for introspection. * **Status / priority aliases:** English aliases (`in_progress`, `ready_for_review`, `done`, `high`, ...) are accepted on write and translated to the Danish enum values stored internally. Responses always use the Danish enums. * **Audit log:** every write is recorded against the key. ## Conventions Success responses: ```json { "success": true, "data": , "meta": { ... optional } } ``` Error responses: ```json { "success": false, "error": { "code": "validation_error", "message": "..." } } ``` Full error-code list: see the `ErrorCode` schema below. ## Actor classification and data visibility Scopes and data visibility are two **separate** decisions. A scope says what an actor may *do*; the actor class on the key says what it may *see*. | Actor class | Sees evidence with visibility | |---|---| | `internal` | `internal`, `customer`, `public` | | `service` | `internal`, `customer`, `public` | | `customer` | `customer`, `public` | | `integration` | `customer`, `public` | The gate is applied to the evidence row set *before* anything is derived, so a customer-side actor cannot infer that hidden internal evidence exists — not via counts, integrity gaps, supersede chains, graph nodes, timeline entries or metadata. Evidence the actor may not see returns `404 not_found` on detail lookup; existence is never confirmed. Customer-side projections additionally blank `metadata`, reviewer ids (`verified_by`, `rejected_by`), `rejection_reason` and the creating user/key ids. servers: - url: https://nodeos.dk description: Production (apex — do NOT use www.) security: - apiKey: [] tags: - name: Work sessions description: | Delivery evidence for human, AI and system work (Work Sessions Contract v1.0). The server owns time; events are append-only. - name: Delivery evidence description: | Append-only delivery evidence (Delivery Evidence Contract v1.0). Facts are immutable; nothing is ever deleted. Verification is a human act and requires the explicit `evidence.verify` scope — it is never implied by `admin.full`, and an actor can never verify its own evidence. - name: Health description: Public uptime probe. No auth. - name: Introspection description: Discover what the calling API key can do. - name: Projects description: Customer/project areas the API key can access. - name: Tasks description: Create, read, update tasks (internal name "items"). - name: Task links description: Commit / PR / URL references attached to a task. - name: Task relations description: Related-task links (`blocks`, `relates_to`, `duplicates`). - name: Task history description: Status change history and lead / cycle time metrics. - name: Tags description: Distinct tags in use for the calling key's organization. - name: Comments description: Read and write task comments. - name: Docs description: Machine-readable API reference for AI agents without JS. - name: Webhooks description: | Register URLs to receive HMAC-signed POST callbacks when tasks change or comments are added. Scopes: `webhooks.read`, `webhooks.manage`. components: securitySchemes: apiKey: type: http scheme: bearer bearerFormat: nw_live_ schemas: DocSyncStatus: type: string enum: [ never_synchronized, synchronized, in_review, pending_review, outdated, synced_release_rolled_back, unknown, ] DocSyncReview: type: object properties: id: { type: string, format: uuid } status: { type: string, enum: [PENDING, IN_REVIEW, SUPERSEDED] } priority: { type: string, example: high } impact_category: { type: string, example: breaking } affected_reason: { type: string } created_at: { type: string, format: date-time } target_release: type: object properties: id: { type: string, format: uuid } release_no: { type: integer } api_version: { type: string, nullable: true } title: { type: string } summary: { type: string, nullable: true } documents: type: array items: type: object properties: source_key: { type: string } path: { type: string, nullable: true } change_kind: { type: string } change_summary: { type: string, nullable: true } DocSource: type: object properties: id: { type: string, format: uuid } key: { type: string } title: { type: string } kind: { type: string } repository: { type: string, nullable: true } path: { type: string, nullable: true } branch: { type: string, nullable: true } external_url: { type: string, nullable: true } updated_at: { type: string, format: date-time } current_version: type: object nullable: true properties: id: { type: string, format: uuid } version_no: { type: integer } content_hash: { type: string } repository_revision: { type: string, nullable: true } created_at: { type: string, format: date-time } DocRelease: type: object properties: id: { type: string, format: uuid } release_no: { type: integer } api_version: { type: string, nullable: true } title: { type: string } summary: { type: string, nullable: true } impact_category: { type: string } target_mode: { type: string, enum: [all_active, selected] } published_at: { type: string, format: date-time, nullable: true } documents: type: array items: type: object properties: source_key: { type: string } path: { type: string, nullable: true } change_kind: { type: string } change_summary: { type: string, nullable: true } MemoryResult: type: object description: | One retrieved development-memory record. `snippet` marks matches with `<<` and `>>`. Results are evidence pointers, not answers. properties: source_type: type: string enum: [item, decision, comment, evidence, work_session, documentation] source_id: { type: string, format: uuid } item_id: { type: string, format: uuid, nullable: true } item_title: { type: string, nullable: true } title: { type: string, nullable: true } snippet: { type: string, nullable: true } matched_field: { type: string, nullable: true } rank: { type: number } occurred_at: { type: string, format: date-time, nullable: true } canonical_path: { type: string, nullable: true } meta: { type: object, nullable: true, additionalProperties: true } AgentDocumentRef: type: object properties: id: { type: string, format: uuid } document_type: { type: string, enum: [steering, system_prompt, client_config, other] } title: { type: string } provider: { type: string, enum: [github, local, external] } repository: { type: string, nullable: true } path: { type: string, nullable: true } branch: { type: string, nullable: true } external_url: { type: string, nullable: true } last_known_revision: { type: string, nullable: true } last_known_hash: { type: string, nullable: true } is_confirmed: { type: boolean } confirmed_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } EvidenceType: type: string enum: [commit, pull_request, branch, build, test_run, deployment, migration, documentation, release_note, review, approval, artifact, external_link, other] EvidenceStatus: type: string description: | `created` → agent/system registered it (never verified). `verified` / `rejected` → set by a human with `evidence.verify`. `superseded` → replaced by newer evidence; kept for audit. enum: [created, verified, rejected, superseded] EvidenceVisibility: type: string enum: [internal, customer, public] EvidenceStrength: type: string description: | Derived beviskraft. Base strength comes from `evidence_type`; human verification bumps it one step up, and evidence with neither `url` nor `external_id` one step down. `rejected` and `superseded` evidence is always `NONE`. enum: [NONE, LOW, MEDIUM, HIGH, CRITICAL] IntegrityResult: type: string description: "INSUFFICIENT 0-24, WEAK 25-54, MODERATE 55-84, STRONG 85-100. STRONG requires human verification." enum: [INSUFFICIENT, WEAK, MODERATE, STRONG] IntegrityGap: type: string enum: [ no_evidence, no_active_evidence, no_implementation_evidence, no_verification_evidence, no_deployment_evidence, no_human_verification, no_customer_visible_evidence, released_without_approval, evidence_without_anchor, only_low_strength_evidence, ] DeliveryIntegrity: type: object description: | Derived delivery confidence for one task. Nothing is persisted and the result is deterministic for a given evidence set. properties: item_id: { type: string, format: uuid } confidence_score: { type: integer, minimum: 0, maximum: 100 } integrity_result: { $ref: "#/components/schemas/IntegrityResult" } evidence_total: { type: integer } evidence_active: { type: integer } evidence_verified: { type: integer } strongest: { $ref: "#/components/schemas/EvidenceStrength" } strength_points: { type: integer } coverage: type: object properties: implementation: { type: boolean } verification: { type: boolean } deployment: { type: boolean } human_verification: { type: boolean } customer_visible: { type: boolean } approval: { type: boolean } by_strength: type: object additionalProperties: { type: integer } chains: type: array description: Supersede lineages, oldest first. The last id is authoritative. items: type: object properties: ids: { type: array, items: { type: string, format: uuid } } head_id: { type: string, format: uuid } length: { type: integer } head_status: { $ref: "#/components/schemas/EvidenceStatus" } gaps: type: array items: type: object properties: gap: { $ref: "#/components/schemas/IntegrityGap" } reason: { type: string } evidence: type: array items: type: object properties: id: { type: string, format: uuid } evidence_type: { $ref: "#/components/schemas/EvidenceType" } status: { $ref: "#/components/schemas/EvidenceStatus" } verified: { type: boolean } visibility: { $ref: "#/components/schemas/EvidenceVisibility" } provider: { type: string, nullable: true } strength: { $ref: "#/components/schemas/EvidenceStrength" } created_at: { type: string, format: date-time } graph: type: object properties: nodes: type: array items: type: object properties: id: { type: string } kind: type: string enum: [evidence, item, work_session, release, decision, user] edges: type: array items: type: object properties: from: { type: object } to: { type: object } relation: type: string enum: [produced, verifies, supersedes, approves, released_in, supports, documents] contract_version: { type: string, example: "1.0" } DeliveryEvidence: type: object properties: id: { type: string, format: uuid } organization_id: { type: string, format: uuid } item_id: { type: string, format: uuid } work_session_id: { type: string, format: uuid, nullable: true } release_id: { type: string, format: uuid, nullable: true } decision_id: { type: string, format: uuid, nullable: true } evidence_type: { $ref: "#/components/schemas/EvidenceType" } status: { $ref: "#/components/schemas/EvidenceStatus" } visibility: { $ref: "#/components/schemas/EvidenceVisibility" } title: { type: string } summary: { type: string, nullable: true } provider: { type: string, nullable: true } external_id: { type: string, nullable: true } url: { type: string, nullable: true } metadata: { type: object, additionalProperties: true } verified: { type: boolean } verified_by: { type: string, format: uuid, nullable: true } verified_at: { type: string, format: date-time, nullable: true } rejected_by: { type: string, format: uuid, nullable: true } rejected_at: { type: string, format: date-time, nullable: true } rejection_reason: { type: string, nullable: true } superseded_by: { type: string, format: uuid, nullable: true } superseded_at: { type: string, format: date-time, nullable: true } created_by_actor_type: { type: string, enum: [human, ai_agent, system, hybrid] } created_by_user_id: { type: string, format: uuid, nullable: true } created_via_api_key_id: type: string format: uuid nullable: true description: | Live reference to the creating API key. Set to `null` when that key is physically deleted (v1.20.1); durable attribution then lives in the snapshot fields below. Internal audiences only. created_via_api_key_prefix: type: string nullable: true description: | Immutable, non-secret snapshot of the creating key's public prefix, captured at insert time so attribution survives key deletion. Never contains the key itself or its hash. Internal audiences only. created_via_api_key_name: type: string nullable: true description: Immutable snapshot of the creating key's display name. Internal audiences only. created_by_agent_kind: type: string nullable: true description: Immutable snapshot of the creating key's agent kind. Internal audiences only. created_by_agent_label: type: string nullable: true description: Immutable snapshot of the creating agent's model/runtime label. Internal audiences only. contract_version: { type: string, example: "1.0" } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } ApiActorClass: type: string description: | Actor classification of the calling API key (Phase 2.2A). Stored on the key itself and independent of scopes: a scope says what an actor may *do*, the actor class says what it may *see*. * `internal` — NodeOS staff key. Sees `internal`, `customer` and `public` evidence. * `service` — trusted first-party automation. Same visibility as `internal`. * `customer` — a key issued to (or on behalf of) the customer. Sees only `customer` and `public` evidence. * `integration` — third-party integration. Treated as customer-side: `customer` and `public` only. enum: [internal, customer, integration, service] DeliveryAudience: type: string description: Effective audience the response was rendered for. enum: [internal, customer] DeliveryTaskSummary: type: object properties: id: { type: string, format: uuid } title: { type: string } status: { type: string } type: { type: string, nullable: true } priority: { type: string, nullable: true } roadmap_bucket: { type: string, nullable: true } start_date: { type: string, format: date, nullable: true } deadline: { type: string, format: date, nullable: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } DeliverySession: type: object description: Internal audience only. properties: id: { type: string, format: uuid } purpose: { type: string } status: { type: string, enum: [active, completed, abandoned, timed_out] } outcome: { type: string, nullable: true } summary: { type: string, nullable: true } actor_type: { type: string, enum: [human, ai_agent, system, hybrid] } actor_label: { type: string, nullable: true } agent_kind: { type: string, nullable: true } agent_model: { type: string, nullable: true } started_at: { type: string, format: date-time } ended_at: { type: string, format: date-time, nullable: true } DeliveryWorkSummary: type: object description: | Item-level work totals. All `*_seconds` fields are integer seconds and all `*_hours` fields are decimal hours rounded to two places. **The item-level `wall_clock_span` is a server-computed union of every active interval on the task — overlapping sessions are counted once.** It must never be reconstructed client-side by summing per-session values; that produces `session_time_sum`, a different number. A customer audience receives only `calendar_elapsed_hours`, `sessions_total` and `sessions_completed`; the remaining fields and the `sessions` array are internal. properties: wall_clock_span_seconds: { type: integer, description: Union of active intervals, seconds. } wall_clock_span_hours: { type: number } session_time_sum_seconds: { type: integer, description: Sum of active intervals; overlaps counted more than once. } session_time_sum_hours: { type: number } calendar_elapsed_seconds: { type: integer, description: First start → last end (or now), seconds. } calendar_elapsed_hours: { type: number } has_open_interval: { type: boolean } sessions_total: { type: integer } sessions_active: { type: integer, description: Internal audience only. } sessions_completed: { type: integer } actor_types: type: array description: Internal audience only. Sorted, distinct. items: { type: string, enum: [human, ai_agent, system, hybrid] } sessions: type: array description: Internal audience only. items: { $ref: "#/components/schemas/DeliverySession" } DeliveryTimelineActor: type: object nullable: true description: | Normalised actor projection. `null` when the entry has no attributable actor (status changes, releases, work events). properties: type: { type: string, enum: [human, ai_agent, system, hybrid] } label: type: string nullable: true description: | Session actor label, or the evidence provider for evidence entries. Null when no label was recorded. DeliveryTimelineEntry: type: object description: | One normalised entry in the unified delivery timeline. Ordering is server-owned: ascending `at`, tie-broken by `ref_id` for stability. properties: at: { type: string, format: date-time, description: Server-owned timestamp. Client-reported times are never used for ordering. } source: type: string enum: [work_session, work_event, evidence, status, release] kind: type: string description: | Stable machine key. Known values: `session.started`, `session.`, `event.`, `evidence.created`, `evidence.verified`, `evidence.rejected`, `evidence.superseded`, `status.changed`, `release.published`. title: { type: string } detail: { type: string, nullable: true } audience: allOf: [{ $ref: "#/components/schemas/DeliveryAudience" }] description: Lowest audience allowed to see the entry. Customer responses contain `customer` entries only. ref_id: { type: string, nullable: true, description: Id of the source row in its own domain. } actor: { $ref: "#/components/schemas/DeliveryTimelineActor" } DeliveryEvidenceSummary: type: object description: | Evidence block of the delivery read model. `total` and `verified` count only the evidence the calling actor may see, so counts can never be used to infer that hidden internal evidence exists. properties: total: { type: integer } verified: { type: integer } items: type: array description: Ascending `created_at`. Customer audiences receive `metadata` as `{}` and internal reviewer ids as `null`. items: { $ref: "#/components/schemas/DeliveryEvidence" } DeliveryIntegritySummary: type: object description: | Compact integrity projection returned to customer audiences. Internal audiences receive the full `DeliveryIntegrity` object instead. properties: confidence_score: { type: integer, minimum: 0, maximum: 100 } integrity_result: { $ref: "#/components/schemas/IntegrityResult" } evidence_active: { type: integer } evidence_verified: { type: integer } strongest: { $ref: "#/components/schemas/EvidenceStrength" } gaps: type: array items: { $ref: "#/components/schemas/IntegrityGap" } DeliveryReleaseSummary: type: object description: "Official releases the task is part of. Customer audiences only see releases with visibility `customer`." properties: id: { type: string, format: uuid } version: { type: string } title: { type: string } released_at: { type: string, format: date-time, nullable: true } visibility: { type: string, enum: [internal, customer] } ReleaseSnapshot: type: object description: | Immutable freeze of what was known at the moment a task entered `Released`. Append-only: later evidence, verification or algorithm changes never rewrite an existing snapshot. Customer audiences receive only `id`, `released_at`, `integrity_result` and `confidence_score`. properties: id: { type: string, format: uuid } released_at: { type: string, format: date-time } integrity_result: { $ref: "#/components/schemas/IntegrityResult" } confidence_score: { type: integer, minimum: 0, maximum: 100 } completion_score: { type: integer, nullable: true, description: Completion quality score at capture time; null when the checklist could not be evaluated. } active_evidence_count: { type: integer } verified_evidence_count: { type: integer } api_version: { type: string, example: "1.20.0" } integrity_algorithm_fingerprint: type: string example: "strength-v1+coverage-v1+bands-85/55/25" description: Identifies the maths behind `confidence_score`, so a historical score stays interpretable after the weights are retuned. DeliveryMeta: type: object properties: read_model_version: { type: string, example: "1.0" } integrity_contract_version: { type: string, example: "1.0" } integrity_algorithm_fingerprint: { type: string } audience: { $ref: "#/components/schemas/DeliveryAudience" } TaskDelivery: type: object description: | Aggregated, server-owned delivery projection for one task. Read-only — it derives everything from the append-only domains and stores nothing. properties: task: { $ref: "#/components/schemas/DeliveryTaskSummary" } delivered: { type: boolean, description: True when item status is `Released`. Item status remains the workflow source of truth. } work: { $ref: "#/components/schemas/DeliveryWorkSummary" } evidence: { $ref: "#/components/schemas/DeliveryEvidenceSummary" } integrity: oneOf: - { $ref: "#/components/schemas/DeliveryIntegrity" } - { $ref: "#/components/schemas/DeliveryIntegritySummary" } description: Full object for internal audiences, compact summary for customer audiences. releases: type: array items: { $ref: "#/components/schemas/DeliveryReleaseSummary" } release_snapshots: type: array description: Newest first. A `Released` task with an empty array predates snapshot capture (legacy state). items: { $ref: "#/components/schemas/ReleaseSnapshot" } timeline: type: array description: Omitted entirely when `?timeline=false`. items: { $ref: "#/components/schemas/DeliveryTimelineEntry" } TaskDeliveryResponse: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/TaskDelivery" } meta: { $ref: "#/components/schemas/DeliveryMeta" } WorkSessionTime: type: object description: | Three separate numbers. Never summed into one, and never presented as "human-equivalent time". The clock runs only while status is `active`. properties: wall_clock_span_seconds: type: integer description: Union of active intervals; overlaps counted once. session_time_sum_seconds: type: integer description: Sum of active intervals; overlaps counted more than once. calendar_elapsed_seconds: type: integer description: First start to last end. wall_clock_span_hours: { type: number } session_time_sum_hours: { type: number } calendar_elapsed_hours: { type: number } has_open_interval: { type: boolean } basis: { type: string, enum: [server_clock] } WorkSession: type: object properties: id: { type: string, format: uuid } item_id: { type: string, format: uuid } organization_id: { type: string, format: uuid } actor_type: { type: string, enum: [human, ai_agent, system, hybrid] } actor_label: { type: string, nullable: true } actor_user_id: { type: string, format: uuid, nullable: true } agent: type: object properties: kind: { type: string, nullable: true } model: { type: string, nullable: true } runtime: { type: string, nullable: true } version: { type: string, nullable: true } purpose: { type: string } status: type: string enum: [active, paused, waiting_for_human, waiting_for_system, waiting_for_external, completed, failed, cancelled] outcome: type: string nullable: true enum: [delivered, partially_delivered, blocked, no_change, failed, cancelled] summary: { type: string, nullable: true } risks: { type: string, nullable: true } correlation_id: { type: string, nullable: true } parent_session_id: { type: string, format: uuid, nullable: true } external_session_id: { type: string, nullable: true } contract_version: { type: string, example: "1.0" } started_at: { type: string, format: date-time } last_activity_at: { type: string, format: date-time } ended_at: { type: string, format: date-time, nullable: true } end_reason: { type: string, nullable: true, enum: [explicit, timeout, admin] } event_seq: { type: integer } event_count: type: integer description: Number of events on the session. Returned by `GET /work-sessions/{id}` only. metadata: { type: object, additionalProperties: true } time: { $ref: "#/components/schemas/WorkSessionTime" } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } events: type: array description: Present only with `include=events`. items: { $ref: "#/components/schemas/WorkEvent" } WorkEvent: type: object properties: id: { type: string, format: uuid } work_session_id: { type: string, format: uuid } item_id: { type: string, format: uuid } seq: { type: integer, description: Server-assigned ordering. } event_type: { type: string } message: { type: string, nullable: true } occurred_at: { type: string, format: date-time, description: Server clock. } client_reported_at: { type: string, format: date-time, nullable: true } correlation_id: { type: string, nullable: true } metadata: { type: object, additionalProperties: true } ErrorCode: type: string description: | Stable machine-readable error codes returned in `error.code`. Agents should branch on this value, not on `error.message`. enum: - missing_api_key - invalid_api_key - key_revoked - key_expired - scope_forbidden - area_forbidden - validation_error - field_not_allowed - invalid_json - unsupported_media_type - payload_too_large - not_found - query_failed - insert_failed - update_failed - internal_error - rate_limited - conflict - invalid_transition - session_terminal - idempotency_key_reuse - idempotency_in_progress Error: type: object required: [success, error] properties: success: type: boolean enum: [false] error: type: object required: [code, message] properties: code: $ref: "#/components/schemas/ErrorCode" message: type: string example: "`title` is required" PaginationMeta: type: object description: Returned on list endpoints inside `meta.pagination`. properties: limit: { type: integer, minimum: 1, maximum: 200, example: 100 } offset: { type: integer, minimum: 0, example: 0 } total: { type: integer, minimum: 0, example: 273 } next_offset: type: integer nullable: true description: Offset to pass for the next page, or null if no more rows. example: 100 Health: type: object required: [status, version, timestamp, database] properties: status: { type: string, enum: [ok, error], example: ok } version: { type: string, example: "1.5.0" } timestamp: { type: string, format: date-time } database: { type: string, enum: [ok, error], example: ok } message: type: string description: Present only when `status` is `error`. Me: type: object required: [key_id, key_prefix, access_level, scopes, organization, areas, scope] properties: key_id: { type: string, format: uuid } name: { type: string, nullable: true } key_prefix: { type: string, example: "nw_live" } access_level: type: string enum: [read_only, read_write, full_access] scopes: type: array items: { type: string, example: "tasks.create" } organization: type: object properties: id: { type: string, format: uuid } name: { type: string, nullable: true } areas: type: array description: | Areas this key is restricted to. Empty array means the key has access to ALL areas in the organization. items: type: object properties: id: { type: string, format: uuid } name: { type: string } scope: type: string enum: [organization, areas] description: "`organization` = all areas; `areas` = restricted list." expires_at: { type: string, format: date-time, nullable: true } revoked_at: { type: string, format: date-time, nullable: true } last_used_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } Project: type: object properties: id: { type: string, format: uuid } name: { type: string } description: { type: string, nullable: true } is_active: { type: boolean } created_at: { type: string, format: date-time } CompletionChecklist: type: object description: | Dynamisk beregnet Completion Checklist / Sagskvalitet. Ingen data gemmes; beregnes on-the-fly. Reglerne filtreres pr. viewer FØR beregningen, så kunde-viewere aldrig ser interne mangler i quality_score eller i missing[]. properties: quality_score: type: integer minimum: 0 maximum: 100 description: Andel af RELEVANTE required-regler der er opfyldt. 100 når required_total = 0. color: type: string enum: [green, yellow, red] description: green ≥ 100, yellow 70–99, red < 70. required_completed: { type: integer, minimum: 0 } required_total: { type: integer, minimum: 0 } recommended_completed: { type: integer, minimum: 0 } recommended_total: { type: integer, minimum: 0 } applicable_total: type: integer minimum: 0 description: Antal regler i scope som FAKTISK gælder (required + recommended, ekskl. N/A). applicable_completed: { type: integer, minimum: 0 } not_applicable_total: type: integer minimum: 0 description: | Antal regler i scope som er irrelevante for denne type arbejde (fx kommercielle felter på en ikke-fakturerbar sag). De indgår hverken i tæller eller nævner. missing: type: array items: { $ref: "#/components/schemas/CompletionChecklistItem" } satisfied: type: array items: { $ref: "#/components/schemas/CompletionChecklistItem" } not_applicable: type: array description: Regler der ikke gælder for sagen; hver har applicable=false og not_applicable_reason. items: { $ref: "#/components/schemas/CompletionChecklistItem" } all: type: array items: { $ref: "#/components/schemas/CompletionChecklistItem" } CompletionChecklistItem: type: object properties: id: { type: string, description: Stabilt regel-slug. } field: { type: string, description: Task-feltet reglen handler om. } label: { type: string, description: Dansk visningstekst. } category: type: string enum: [basic, development, customer, communication, economics, planning] severity: type: string enum: [required, recommended] satisfied: { type: boolean } applicable: type: boolean description: false = kravet gælder ikke for denne sag og indgår ikke i scoren. not_applicable_reason: type: string nullable: true description: Sættes kun når applicable = false. reason: { type: string, description: Forklaring på hvorfor reglen gælder — bruges af AI-klienter., } CompletionSummary: type: object description: | Kompakt kvalitetsoverblik inkluderet på hver task i list-responsen når `GET /tasks?include=completion` er angivet. Beregnes med samme regler som CompletionChecklist, men uden `missing[]`/`satisfied[]` for at holde payload'en lille. properties: quality_score: { type: integer, minimum: 0, maximum: 100 } color: { type: string, enum: [green, yellow, red] } Task: type: object properties: id: { type: string, format: uuid } area_id: { type: string, format: uuid, nullable: true } title: { type: string } description: { type: string, nullable: true } customer_summary: type: string nullable: true description: Kundevendt problembeskrivelse (hvad oplever kunden). Fri fra teknisk sprog. customer_solution: type: string nullable: true description: Kundevendt løsningsbeskrivelse (hvad blev gjort) i klart sprog. customer_value: type: string nullable: true description: Kundevendt værdi / effekt af leverancen. release_note: type: string nullable: true description: Kort changelog-linje brugt på Leverancer-siden når status = Released. technical_notes: type: string nullable: true description: Intern teknisk analyse (root cause, SQL, filer, commits, linjenumre). Skjules for kunder. customer_reply: type: string nullable: true description: | Foreslået eller sendt svar til henvenderen. Bruges primært på Support/Fejl. Feltet dokumenterer teksten — det beviser ikke, at svaret er sendt. Skjules i kundevendte visninger. completion: oneOf: - $ref: "#/components/schemas/CompletionChecklist" - $ref: "#/components/schemas/CompletionSummary" description: | Completion status. On `GET /tasks/{id}` (and `GET /tasks/{id}/completion`) this is the full CompletionChecklist. On `GET /tasks?include=completion` it is a compact CompletionSummary (`quality_score` + `color`) suitable for dashboards and audits. customer_impact: type: string nullable: true description: Kort beskrivelse af kundens oplevede påvirkning (fx "kan ikke logge ind"). Intern og økonomi-relateret. payment_responsibility: type: string enum: [customer, owner, shared, not_decided, not_billable] description: Hvem betaler. Ikke nullable — brug `not_decided` / `not_billable` for at rydde. billable_status: type: string enum: [Ikke vurderet, Fakturerbar, Ikke fakturerbar, Afventer godkendelse, Godkendt] description: Fakturerings-status. Ikke nullable — brug `Ikke vurderet` for at rydde. billing_reason: type: string nullable: true enum: [bug_fix, new_development, change_request, support, technical_debt, goodwill, unclear] description: | Klassifikation af hvorfor sagen er (ikke) fakturerbar. Skal være en af enum-værdierne (håndhæves nu af DB-check og API-validering — invalide værdier returnerer 400, ikke 500). billing_note: type: string nullable: true maxLength: 2000 description: Fri notetekst til fakturering (intern). estimate_hours: type: number nullable: true minimum: 0 maximum: 10000 description: Estimat i timer (op til 2 decimaler). actual_hours: type: number nullable: true minimum: 0 maximum: 10000 description: Registreret tidsforbrug (op til 2 decimaler). created_via_intake_form_id: type: string format: uuid nullable: true description: Intake-formular sagen kom fra (hvis oprettet via portal-formular). type: type: string enum: [Fejl, Udviklingsønske, Ændringsønske, Beslutning, Teknisk opgave, Support] status: type: string enum: [ Ny, Afventer afklaring, Godkendt, Planlagt, Under udvikling, Klar til test, Godkendt af kunde, Released, Parkeret, Afvist, ] priority: type: string enum: [Lav, Medium, Høj, Kritisk] roadmap_bucket: type: string nullable: true enum: [Nu, Næste, Senere, Parkeret] assigned_to: { type: string, format: uuid, nullable: true } requested_by: type: string format: uuid nullable: true description: | Profil-UUID for indmelderen (skal være aktivt medlem af organisationen). Bruges når indmelderen har en NodeOS-konto. Se også reporter_* felterne. reporter_name: type: string nullable: true maxLength: 200 description: Navn på ekstern henvender uden NodeOS-profil. PII — kun synligt for interne brugere. reporter_email: type: string format: email nullable: true maxLength: 320 description: | E-mail på ekstern henvender. Skjules for kundebrugere der ikke selv er reporter. Bruges også til automatisk profilmatch ved POST (kun ved entydigt match i samme organisation). reporter_organization: type: string nullable: true maxLength: 200 description: Firma/afdeling for ekstern henvender. Kun synligt for interne brugere. reporter_channel: type: string nullable: true enum: [portal, email, phone, meeting, api, other] description: Kanal sagen kom ind ad. Kun synligt for interne brugere. parent_item_id: type: string format: uuid nullable: true description: Parent initiative id (delopgave af et initiativ). Max ét niveau. start_date: type: string format: date nullable: true description: | Planlagt startdato (YYYY-MM-DD). NodeOS sætter automatisk feltet til dagens dato (Europe/Copenhagen) første gang sagen rammer status "Under udvikling" — også ved oprettelse direkte i den status. En manuelt sat dato overskrives aldrig. deadline: type: string format: date nullable: true description: Frist (YYYY-MM-DD). Skal være ≥ start_date. tags: type: array items: { type: string, maxLength: 50 } maxItems: 20 description: Liste af labels til at gruppere på tværs af områder. Tomt array når der ingen tags er. visibility: { type: string, enum: [internal, customer] } created_via_api_key_id: { type: string, format: uuid, nullable: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } Comment: type: object properties: id: { type: string, format: uuid } body: { type: string } visibility: { type: string, enum: [internal, customer] } user_id: { type: string, format: uuid, nullable: true } created_via_api_key_id: { type: string, format: uuid, nullable: true } created_at: { type: string, format: date-time } TaskLink: type: object description: External reference attached to a task (commit, PR, or plain URL). properties: id: { type: string, format: uuid } item_id: { type: string, format: uuid } type: type: string enum: [commit, pr, url] url: { type: string, format: uri, maxLength: 2048 } title: type: string nullable: true maxLength: 200 description: Optional display label. created_by: { type: string, format: uuid, nullable: true } created_via_api_key_id: { type: string, format: uuid, nullable: true } created_at: { type: string, format: date-time } TaskRelation: type: object description: | Directed link between two tasks in the same organization. `blocks` and `is_blocked_by` are mirrored automatically; `relates_to` and `duplicates` are symmetric. properties: id: { type: string, format: uuid } source_item_id: { type: string, format: uuid } target_item_id: { type: string, format: uuid } relation_type: type: string enum: [relates_to, blocks, is_blocked_by, duplicates] created_by: { type: string, format: uuid, nullable: true } created_via_api_key_id: { type: string, format: uuid, nullable: true } created_at: { type: string, format: date-time } TaskStatusHistory: type: object description: | A single status segment on a task. `exited_at` is null for the current (open) segment; `duration_seconds` is the seconds spent in that status (null while the segment is still open). properties: id: { type: string, format: uuid } item_id: { type: string, format: uuid } status: type: string enum: [ Ny, Afventer afklaring, Godkendt, Planlagt, Under udvikling, Klar til test, Godkendt af kunde, Released, Parkeret, Afvist, ] entered_at: { type: string, format: date-time } exited_at: { type: string, format: date-time, nullable: true } duration_seconds: { type: integer, nullable: true, minimum: 0 } changed_by: { type: string, format: uuid, nullable: true } changed_via_api_key_id: { type: string, format: uuid, nullable: true } TagUsage: type: object properties: tag: { type: string, maxLength: 50 } count: { type: integer, minimum: 1 } WebhookEndpoint: type: object description: | Subscriber registration. `secret` is returned only by `POST /webhooks`; `secret_last4` is shown on all subsequent reads. properties: id: { type: string, format: uuid } url: { type: string, format: uri } events: type: array items: type: string enum: [task.created, task.updated, task.status_changed, comment.created, webhook.test] area_ids: type: array nullable: true items: { type: string, format: uuid } description: | Reserved for future per-area filtering. Currently ignored by the dispatcher — all events for the organization are delivered. is_active: { type: boolean } description: { type: string, nullable: true } secret_last4: { type: string, example: "…a3f9" } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } last_triggered_at: { type: string, format: date-time, nullable: true } last_status_code: { type: integer, nullable: true } consecutive_failures: { type: integer, minimum: 0 } WebhookDelivery: type: object properties: id: { type: string, format: uuid } event: type: string enum: [task.created, task.updated, task.status_changed, comment.created, webhook.test] response_status: { type: integer, nullable: true, description: "0 = network/timeout error" } duration_ms: { type: integer, nullable: true } success: { type: boolean } attempt_count: { type: integer, minimum: 0 } attempted_at: { type: string, format: date-time } next_retry_at: type: string format: date-time nullable: true description: "When the next retry is scheduled. NULL = no further retries." responses: Unauthorized: description: Missing, invalid, revoked, or expired API key. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: success: false error: { code: invalid_api_key, message: "API key is invalid" } Forbidden: description: Authenticated, but the key lacks the required scope or area access. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: success: false error: { code: scope_forbidden, message: "Missing required scope: tasks.create" } NotFound: description: Resource does not exist or is not visible to this API key. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: success: false error: { code: not_found, message: "Task not found" } ValidationError: description: Request body or parameters failed validation. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: success: false error: { code: validation_error, message: "`title` is required" } parameters: Limit: in: query name: limit schema: { type: integer, minimum: 1, maximum: 200, default: 100 } description: Maximum rows to return. Capped at 200. Offset: in: query name: offset schema: { type: integer, minimum: 0, default: 0 } description: Number of rows to skip for pagination. paths: /api/public/health: get: tags: [Health] operationId: getHealth summary: Health probe description: | Returns 200 when a trivial database query succeeds, 503 otherwise. No authentication required. Suitable for uptime monitors. security: [] responses: "200": description: Healthy content: application/json: schema: { $ref: "#/components/schemas/Health" } example: status: ok version: "1.5.0" timestamp: "2026-06-19T12:34:56Z" database: ok "503": description: Database probe failed. content: application/json: schema: { $ref: "#/components/schemas/Health" } /api/public/v1/docs: get: tags: [Docs] operationId: getDocs summary: Plaintext API reference (markdown) description: | Returns the canonical `docs/API.md` as `text/markdown`. For AI agents and other clients that cannot execute JavaScript and therefore cannot consume the SPA-rendered `/docs` (Redoc) page. Pass `Accept: application/json` to receive a JSON envelope: ```json { "success": true, "data": { "format": "markdown", "openapi_url": "/openapi.yaml", "human_url": "/docs", "content": "# NodeOS Public API\n..." } } ``` No authentication required. security: [] responses: "200": description: "Markdown body, or JSON envelope when `Accept: application/json`." content: text/markdown: schema: { type: string } application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: format: { type: string, example: markdown } openapi_url: { type: string, example: /openapi.yaml } human_url: { type: string, example: /docs } content: { type: string } /api/public/v1/me: get: tags: [Introspection] operationId: getMe summary: Introspect the calling API key description: | Returns key id, organization, scopes, allowed areas, expiry, and last usage. Agents should call this once on startup to learn what they can do. responses: "200": description: OK content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/Me" } "401": { $ref: "#/components/responses/Unauthorized" } /api/public/v1/projects: get: tags: [Projects] operationId: listProjects summary: List projects (areas) description: Returns the areas the API key has access to within its organization. responses: "200": description: OK content: application/json: schema: type: object properties: success: { type: boolean } data: type: array items: { $ref: "#/components/schemas/Project" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Projects] operationId: createProject summary: Create a project (area) description: | Requires `projects.manage` scope. Area-restricted API keys are refused (403 `area_forbidden`) because a new area would be invisible to them. Added in v1.13.0. requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string, maxLength: 200 } description: { type: string, nullable: true, maxLength: 2000 } example: { name: "Nyt kundeprojekt", description: "Onboarding og drift" } responses: "201": description: Created content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/Project" } "400": { $ref: "#/components/responses/ValidationError" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /api/public/v1/projects/{id}: parameters: - in: path name: id required: true schema: { type: string, format: uuid } patch: tags: [Projects] operationId: updateProject summary: Update a project (area) description: | Requires `projects.manage`. Updatable fields: `name`, `description`, `is_active`. Added in v1.13.0. requestBody: required: true content: application/json: schema: type: object properties: name: { type: string, maxLength: 200 } description: { type: string, nullable: true, maxLength: 2000 } is_active: { type: boolean } responses: "200": description: OK content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/Project" } "400": { $ref: "#/components/responses/ValidationError" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } delete: tags: [Projects] operationId: deleteProject summary: Soft-delete a project (area) description: | Sets `is_active=false` — hard delete is refused because `items.area_id` references this row. Requires `projects.manage`. Added in v1.13.0. responses: "200": description: Soft-deleted content: application/json: schema: type: object properties: success: { type: boolean } data: allOf: - $ref: "#/components/schemas/Project" - type: object properties: deleted: { type: string, enum: [soft] } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/tasks: get: tags: [Tasks] operationId: listTasks summary: List tasks description: | Returns tasks for the API key's organization (and area allow-list if the key is area-restricted). **Fields (v1.12.0):** the list response now returns the full task shape — including economics (`payment_responsibility`, `billable_status`, `billing_reason`, `billing_note`, `estimate_hours`, `actual_hours`, `customer_impact`) and reporter fields (`reporter_name`, `reporter_email`, `reporter_organization`, `reporter_channel`, `requested_by`). This lets audits and dashboards run against a single call instead of N+1 detail fetches. Pass `?include=completion` to attach a `CompletionSummary` per task (requires `limit ≤ 100`). parameters: - in: query name: area_id schema: { type: string, format: uuid } - in: query name: status schema: { type: string } description: Danish enum value or English alias. - in: query name: search schema: { type: string } description: | Case-insensitive substring match against `title` and `description`, plus exact tag match. Alias: `q`. Useful for duplicate detection before creating a new task. - in: query name: q schema: { type: string } description: Alias for `search`. - in: query name: search_field schema: type: string enum: [title, description, tags, all] default: all description: | Narrows `search` to a single column. `title` is the precise choice for duplicate detection; `all` (default) keeps the legacy broad behaviour (title + description + tag). Added in v1.14.0. - in: query name: exact schema: { type: boolean, default: false } description: | When `true`, `search` must match the whole field value (case-insensitive) instead of a substring. Combine with `search_field=title`. Added in v1.14.0. - in: query name: tags schema: { type: string } description: | Comma-separated list of tags. Rows must contain ALL listed tags (case-sensitive exact match). Example: `?tags=auth,oauth`. - in: query name: type schema: type: string enum: [Fejl, Udviklingsønske, Ændringsønske, Beslutning, Teknisk opgave, Support] description: Danish enum value (English aliases like `bug`, `feature` also accepted). - in: query name: priority schema: type: string enum: [Lav, Medium, Høj, Kritisk] description: Danish enum value (English aliases `low`/`medium`/`high`/`critical` also accepted). - in: query name: created_after schema: { type: string, format: date-time } description: ISO-8601 timestamp. Only tasks with `created_at >= created_after`. - in: query name: created_before schema: { type: string, format: date-time } description: ISO-8601 timestamp. Only tasks with `created_at <= created_before`. - in: query name: updated_after schema: { type: string, format: date-time } description: ISO-8601 timestamp. Only tasks with `updated_at >= updated_after`. - in: query name: include schema: type: string description: | Comma-separated list of optional expansions. Currently supported: `completion` — attach a compact `CompletionSummary` (`quality_score`, `color`) to each task. Requires `limit ≤ 100` (otherwise `validation_error`). - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: OK content: application/json: schema: type: object properties: success: { type: boolean } data: type: array items: { $ref: "#/components/schemas/Task" } meta: type: object properties: pagination: { $ref: "#/components/schemas/PaginationMeta" } example: success: true data: - id: "1e1c..." title: "Fix login redirect" status: "Under udvikling" priority: "Høj" area_id: "a1..." visibility: "internal" created_via_api_key_id: null meta: pagination: { limit: 100, offset: 0, total: 273, next_offset: 100 } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Tasks] operationId: createTask summary: Create a task description: | Requires `tasks.create` scope. For area-restricted keys, `area_id` is required and must be in the key's allowed list. **Idempotency (v1.22.0):** send an `Idempotency-Key` header to make retries safe. Same key + same payload replays the original `201` with `Idempotent-Replay: true`; same key + different payload returns `409 idempotency_key_reuse`; a retry while the first request is still running returns `409 idempotency_in_progress`. parameters: - in: header name: Idempotency-Key required: false schema: { type: string, maxLength: 200 } description: Retry-safe key, scoped to (organization, endpoint, key). requestBody: required: true content: application/json: schema: type: object required: [title] properties: title: { type: string, maxLength: 500 } description: { type: string, nullable: true } customer_summary: { type: string, nullable: true, description: "Kundevendt problembeskrivelse." } customer_solution: { type: string, nullable: true, description: "Kundevendt løsningsbeskrivelse i klart sprog.", } customer_value: { type: string, nullable: true, description: "Kundevendt værdi / effekt af leverancen.", } release_note: { type: string, nullable: true, description: "Kort changelog-linje brugt på Leverancer-siden.", } technical_notes: { type: string, nullable: true, description: "Intern teknisk analyse. Skjules for kunder.", } customer_reply: { type: string, nullable: true, description: "Foreslået eller sendt svar til henvenderen (Support/Fejl). Skjules for kunder.", } area_id: { type: string, format: uuid, nullable: true } type: type: string description: | English alias (`bug`, `feature`, `change`, `decision`, `task`, `support`) or Danish enum value. default: task status: type: string description: | Optional. English aliases like `in_progress`, `ready_for_review`, `done` are translated to the Danish enum. priority: type: string description: Optional. `low` / `medium` / `high` / `critical`. start_date: type: string format: date nullable: true description: Planlagt startdato (YYYY-MM-DD). deadline: type: string format: date nullable: true description: Frist (YYYY-MM-DD). Skal være ≥ start_date. tags: type: array items: { type: string, maxLength: 50 } maxItems: 20 description: Liste af labels (max 20). visibility: type: string enum: [customer, internal] default: customer description: | `customer` (default) gør opgaven synlig for kundens customer-admin/manager/contributor i portalen. `internal` skjuler den for kunden — brug kun til intern back-office. roadmap_bucket: type: string nullable: true enum: [Nu, Næste, Senere, Parkeret] description: Roadmap-placering. Ingen automatisk udledning. requested_by: type: string format: uuid nullable: true description: | Profil-UUID for indmelderen. Skal være aktivt medlem af organisationen. Hvis den er tom og `reporter_email` sat, forsøger NodeOS entydigt profilmatch (case-insensitiv, kun inden for organisationen). reporter_name: type: string nullable: true maxLength: 200 reporter_email: type: string format: email nullable: true maxLength: 320 reporter_organization: type: string nullable: true maxLength: 200 reporter_channel: type: string nullable: true enum: [portal, email, phone, meeting, api, other] parent_item_id: type: string format: uuid nullable: true description: | Parent task. Must belong to the same organization (and, for area-restricted keys, an allowed area) and may not point at the task itself. Added in v1.14.0 — previously a subtask required a second `relations` call. example: title: "Add OAuth callback handler" description: "Implement /auth/callback to exchange code for session." area_id: "a1b2c3d4-e5f6-7890-abcd-1234567890ab" type: "task" status: "in_progress" priority: "high" start_date: "2026-06-20" deadline: "2026-07-04" tags: ["auth", "oauth"] responses: "201": description: Created content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/Task" } "400": { $ref: "#/components/responses/ValidationError" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /api/public/v1/tasks/bulk: post: tags: [Tasks] operationId: bulkCreateTasks summary: Bulk-create tasks (and relations) description: | Best-effort batch CREATE. Up to 50 tasks and 100 relations per request. Each task is validated through the same path as `POST /tasks`, so all field rules are identical. Give a task a `ref` to reference it later in the same payload: another task may set `parent_ref` (creates a real `parent_item_id`), and `relations[]` may use `source_ref` / `target_ref`. A `parent_ref` must point at a task **earlier** in the array. Relations may also use literal `source_task_id` / `target_task_id`. Always returns 200 — inspect `meta.batch` and each entry's `status`. Requires the `tasks.create` scope. Added in v1.14.0. requestBody: required: true content: application/json: schema: type: object required: [tasks] properties: tasks: type: array minItems: 1 maxItems: 50 items: type: object required: [title] description: | Same body as `POST /tasks`, plus optional `ref` and `parent_ref`. properties: ref: { type: string } parent_ref: { type: string } title: { type: string } relations: type: array maxItems: 100 items: type: object properties: source_ref: { type: string } target_ref: { type: string } source_task_id: { type: string, format: uuid } target_task_id: { type: string, format: uuid } relation_type: type: string default: relates_to example: tasks: - ref: "epic" title: "Fase 0 — Signing" type: "feature" - ref: "sub1" title: "Generér signing key" parent_ref: "epic" relations: - source_ref: "epic" target_ref: "sub1" relation_type: "relates_to" responses: "200": description: Batch processed (inspect per-entry status) content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: results: type: array items: type: object properties: ref: { type: string, nullable: true } status: { type: integer } task: { $ref: "#/components/schemas/Task" } error: type: object properties: code: { type: string } message: { type: string } relations: type: array items: type: object properties: source_ref: { type: string, nullable: true } target_ref: { type: string, nullable: true } status: { type: integer } meta: type: object properties: batch: type: object properties: total: { type: integer } created: { type: integer } failed: { type: integer } relations_total: { type: integer } "400": { $ref: "#/components/responses/ValidationError" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /api/public/v1/tasks/batch: post: tags: [Tasks] operationId: batchUpdateTasks summary: Batch PATCH tasks (best-effort) description: | Apply the same field-set as `PATCH /tasks/{id}` to many tasks in one request. Best-effort: each operation is validated and executed independently, so partial success is expected — always returns 200 with a `results` array of per-operation outcomes. Requires `tasks.update`. Cap: 50 operations per request. Added in v1.13.0. Optionally include completion score per row via `include: ["completion"]` at the batch level, or per operation. requestBody: required: true content: application/json: schema: type: object required: [operations] properties: include: type: array items: { type: string, enum: [completion] } operations: type: array minItems: 1 maxItems: 50 items: type: object required: [id, patch] properties: id: { type: string, format: uuid } include: type: array items: { type: string, enum: [completion] } patch: type: object description: Same field set as `PATCH /tasks/{id}` body. example: include: ["completion"] operations: - id: "11111111-1111-1111-1111-111111111111" patch: { status: "Under udvikling", priority: "P2" } - id: "22222222-2222-2222-2222-222222222222" patch: { tags: ["kiro", "sprint-42"] } responses: "200": description: Per-operation results (always 200; inspect `results[].status`) content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: results: type: array items: type: object properties: id: { type: string, format: uuid, nullable: true } status: { type: integer, example: 200 } task: { $ref: "#/components/schemas/Task" } completion: { $ref: "#/components/schemas/CompletionSummary" } error: type: object properties: code: { type: string } message: { type: string } metadata: { type: object, additionalProperties: true } meta: type: object properties: batch: type: object properties: total: { type: integer } succeeded: { type: integer } failed: { type: integer } "400": { $ref: "#/components/responses/ValidationError" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /api/public/v1/tasks/{id}: parameters: - in: path name: id required: true schema: { type: string, format: uuid } get: tags: [Tasks] operationId: getTask summary: Get a task responses: "200": description: OK content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/Task" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } patch: tags: [Tasks] operationId: updateTask summary: Update a task description: | Whitelisted fields only. Any other field returns `field_not_allowed`. Requires `tasks.update` scope. **Content fields:** `title`, `description`, `status`, `priority`, `area_id`, `start_date`, `deadline`, `tags`, `visibility`, `customer_summary`, `customer_solution`, `customer_value`, `release_note`, `technical_notes`, `customer_reply`. **Planning fields:** `roadmap_bucket` — canonical enum `Nu | Næste | Senere | Parkeret` (or null to clear; empty string is rejected). `assigned_to` (UUID of an active org member, or null). **Reporter fields:** `reporter_name`, `reporter_email`, `reporter_organization`, `reporter_channel` (enum: `portal | email | phone | meeting | api | other`), and `requested_by` (profile UUID). All nullable. Empty strings are rejected — use `null` to clear. `requested_by` must reference an active member of the API key's organization; cross-org UUIDs return `validation_error`. **Automatic profile match on PATCH:** only when `requested_by` is currently `NULL`, the client sends `reporter_email` (and does NOT also send `requested_by`), and exactly one active profile in the org matches the email (case-insensitive). Never overrides an existing `requested_by`. **Automatic start_date:** if the PATCH changes `status` to `Under udvikling` and `start_date` is null, NodeOS sets it to today (Europe/Copenhagen). A manually-set date is never overwritten. **Economics fields** (available to any key with `tasks.update`; values stored regardless of whether the org has enabled the field in `intake_settings` — the Completion Checklist reads visibility from settings, but the API accepts writes): * `payment_responsibility` — enum: `customer`, `owner`, `shared`, `not_decided`, `not_billable`. Not nullable. * `billable_status` — enum: `Ikke vurderet`, `Fakturerbar`, `Ikke fakturerbar`, `Afventer godkendelse`, `Godkendt`. Not nullable. * `estimate_hours` — number 0..10000 (2 decimals) or null. * `actual_hours` — number 0..10000 (2 decimals) or null. * `billing_reason` — enum: `bug_fix`, `new_development`, `change_request`, `support`, `technical_debt`, `goodwill`, `unclear` (or null to clear). Invalid values return 400 `validation_error` (fixed in v1.12.0 — previously returned 500). * `billing_note` — string ≤ 2000 chars or null. **customer_reply semantics:** free text ≤ 10000 chars. Plain text (no markdown rendering in the customer portal). Documents the suggested or sent reply to the external reporter — it does **not** prove the reply was delivered. Differs from a `visibility: "customer"` comment: a comment is a durable message in the timeline; `customer_reply` is a single mutable field snapshot used for the Completion Checklist and for AI/agent drafts. requestBody: required: true content: application/json: example: status: "released" actual_hours: 2.5 billable_status: "Fakturerbar" payment_responsibility: "customer" customer_reply: "Vi har rullet en fix ud i dag. Fortæl os hvis det stadig fejler." responses: "200": description: Updated content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/Task" } "400": { $ref: "#/components/responses/ValidationError" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/tasks/{id}/completion: parameters: - in: path name: id required: true schema: { type: string, format: uuid } get: tags: [Tasks] operationId: getTaskCompletion summary: Get task Completion Checklist / Sagskvalitet description: | Returnerer den dynamisk beregnede Completion Checklist for sagen — quality score, farvekode, opdeling i required og recommended, samt lister over manglende og opfyldte felter. Ingen data gemmes; checklisten beregnes on-the-fly ud fra sagstype, status, feltværdier og kundens intake_settings (som styrer hvilke økonomifelter der er aktive). Blokerer aldrig sagens afslutning. Samme objekt inkluderes også som `completion` på GET /tasks/{id}. Kunde-viewere ser kun `basic` + `customer` + `communication`-kategorier; interne (og API-nøgler) ser hele checklisten. responses: "200": description: Completion checklist content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/CompletionChecklist" } example: success: true data: quality_score: 86 color: yellow required_completed: 6 required_total: 7 recommended_completed: 3 recommended_total: 5 missing: - id: payment_responsibility field: payment_responsibility label: Betales af category: economics severity: required satisfied: false reason: Required når økonomi er aktiveret og sagen er under udvikling eller senere (undtagen Beslutning). satisfied: - id: release_note field: release_note label: Release note category: customer severity: required satisfied: true reason: Release note skal være udfyldt før en sag lukkes som Released. "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/tasks/{id}/delivery-integrity: parameters: - in: path name: id required: true schema: { type: string, format: uuid } get: tags: [Delivery evidence] operationId: getTaskDeliveryIntegrity summary: Get delivery integrity and confidence description: | Scope: `evidence.read`. Delivery Integrity Contract v1.0 (Work Sessions Phase 2.1). Alt beregnes on-the-fly ud fra sagens leveringsevidens — intet gemmes, og ingen statusovergang blokeres. Dækning dominerer scoren: implementering (25), verifikation (25), deployment (20), menneskelig verifikation (20) og beviskraft (10). Mængde af evidens alene kan ikke købe en høj score. `rejected` og `superseded` evidens tæller aldrig som bevis. responses: "200": description: Delivery integrity content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/DeliveryIntegrity" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/tasks/{id}/delivery: parameters: - in: path name: id required: true schema: { type: string, format: uuid } get: tags: [Delivery evidence] operationId: getTaskDelivery summary: Aggregated delivery read model for one task description: | Scope: `evidence.read`. Read-only, server-owned projection (Phase 2.2B) that returns work, evidence, integrity, releases, release snapshots and — unless `timeline=false` — the unified timeline in a single call. **Visibility.** The audience is decided by the key's actor class (`internal` / `service` see internal data; `customer` / `integration` do not) and applied to the evidence row set *before* anything is derived. Counts, gaps, chains, graph and timeline therefore cannot be used to infer that hidden internal evidence exists. **Time.** `work.wall_clock_span_*` is a server-computed union across all sessions on the task. Do not re-derive it by summing per-session values. **Advisory.** Integrity and completion are advisory in Phase 2.2; item status remains the workflow source of truth and `actual_hours` is never modified by this endpoint. parameters: - in: query name: timeline required: false schema: { type: string, enum: ["true", "false"], default: "true" } description: Set to `false` to omit the `timeline` array entirely. responses: "200": description: Delivery read model content: application/json: schema: { $ref: "#/components/schemas/TaskDeliveryResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/tasks/{id}/status: parameters: - in: path name: id required: true schema: { type: string, format: uuid } patch: tags: [Tasks] operationId: updateTaskStatus summary: Update task status description: Convenience endpoint for AI agents. Requires `status.update` scope. requestBody: required: true content: application/json: schema: type: object required: [status] properties: status: type: string description: English alias or Danish enum value. example: status: "done" responses: "200": description: OK content: application/json: example: success: true data: id: "9f8e..." status: "Released" updated_at: "2026-06-19T12:34:56Z" "400": { $ref: "#/components/responses/ValidationError" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/tasks/{id}/comments: parameters: - in: path name: id required: true schema: { type: string, format: uuid } get: tags: [Comments] operationId: listTaskComments summary: List comments on a task parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: OK content: application/json: schema: type: object properties: success: { type: boolean } data: type: array items: { $ref: "#/components/schemas/Comment" } meta: type: object properties: pagination: { $ref: "#/components/schemas/PaginationMeta" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } post: tags: [Comments] operationId: createTaskComment summary: Add a comment to a task description: | Tagged with `created_via_api_key_id` so the UI can render "Skrevet via integration". Requires `comments.create` scope. requestBody: required: true content: application/json: schema: type: object required: [body] properties: body: { type: string, maxLength: 10000 } visibility: type: string enum: [internal, customer] default: customer is_customer_reply: type: boolean default: false description: | When true (and `visibility="customer"`), the comment body is also written to `items.customer_reply` in the same request, so KIRO can deliver a customer-facing reply without a separate PATCH. Combining with `visibility="internal"` is rejected (400 `validation_error`). Added in v1.13.0. example: body: "Vi har rullet fixet ud — sagen er lukket." visibility: "customer" is_customer_reply: true responses: "201": description: Created content: application/json: schema: type: object properties: success: { type: boolean } data: allOf: - $ref: "#/components/schemas/Comment" - type: object properties: synced_to_customer_reply: type: boolean description: | True when `is_customer_reply` was requested and the mirror-write to `items.customer_reply` succeeded. Always present on v1.13.0+. "400": { $ref: "#/components/responses/ValidationError" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/webhooks: get: tags: [Webhooks] operationId: listWebhooks summary: List webhook endpoints description: Requires `webhooks.read`. Secret is masked (`secret_last4`). parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: OK content: application/json: schema: type: object properties: success: { type: boolean } data: type: array items: { $ref: "#/components/schemas/WebhookEndpoint" } meta: type: object properties: pagination: { $ref: "#/components/schemas/PaginationMeta" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Webhooks] operationId: createWebhook summary: Register a webhook endpoint description: | Requires `webhooks.manage`. The full `secret` is returned exactly once in this response — store it securely. Future `GET` responses only expose `secret_last4`. requestBody: required: true content: application/json: schema: type: object required: [url, events] properties: url: type: string format: uri description: HTTPS endpoint to POST events to. Max 2048 chars. events: type: array minItems: 1 items: type: string enum: [ task.created, task.updated, task.status_changed, comment.created, webhook.test, ] secret: oneOf: - type: string enum: ["auto"] - type: string minLength: 16 maxLength: 256 description: | `"auto"` (default) generates a 64-char hex secret. Otherwise supply your own (≥16 chars). description: type: string maxLength: 500 nullable: true example: url: "https://example.com/hooks/nodeos" events: ["task.created", "task.status_changed", "comment.created"] description: "KIRO integration" secret: "auto" responses: "201": description: Created. `secret` is shown only here. content: application/json: schema: type: object properties: success: { type: boolean } data: allOf: - $ref: "#/components/schemas/WebhookEndpoint" - type: object properties: secret: { type: string } "400": { $ref: "#/components/responses/ValidationError" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /api/public/v1/webhooks/{id}: parameters: - in: path name: id required: true schema: { type: string, format: uuid } get: tags: [Webhooks] operationId: getWebhook summary: Get one webhook endpoint responses: "200": description: OK content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/WebhookEndpoint" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } patch: tags: [Webhooks] operationId: updateWebhook summary: Update a webhook endpoint description: | Requires `webhooks.manage`. Whitelisted fields: `url`, `events`, `is_active`, `description`. Setting `is_active: true` also resets `consecutive_failures` to 0. requestBody: required: true content: application/json: schema: type: object properties: url: { type: string, format: uri } events: type: array items: type: string enum: [ task.created, task.updated, task.status_changed, comment.created, webhook.test, ] is_active: { type: boolean } description: { type: string, nullable: true } responses: "200": description: OK content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/WebhookEndpoint" } "400": { $ref: "#/components/responses/ValidationError" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } delete: tags: [Webhooks] operationId: deleteWebhook summary: Delete a webhook endpoint responses: "200": description: Deleted content: application/json: example: success: true data: { deleted: true, id: "uuid" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/webhooks/{id}/deliveries: parameters: - in: path name: id required: true schema: { type: string, format: uuid } get: tags: [Webhooks] operationId: listWebhookDeliveries summary: List recent delivery attempts description: Requires `webhooks.read`. Deliveries older than 30 days are purged automatically. parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: OK content: application/json: schema: type: object properties: success: { type: boolean } data: type: array items: { $ref: "#/components/schemas/WebhookDelivery" } meta: type: object properties: pagination: { $ref: "#/components/schemas/PaginationMeta" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/webhooks/{id}/test: parameters: - in: path name: id required: true schema: { type: string, format: uuid } post: tags: [Webhooks] operationId: testWebhook summary: Send a test event to the endpoint description: | Synchronously POSTs a `webhook.test` envelope (with `"test": true`) to the registered URL and returns the delivery result. Useful when validating signature verification during integration. Does not retry. responses: "200": description: Delivery attempt completed (success or failure). content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: delivery_id: { type: string, format: uuid } success: { type: boolean } response_status: { type: integer } duration_ms: { type: integer } error: { type: string, nullable: true } truncated: { type: boolean } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/tags: get: tags: [Tags] operationId: listTags summary: List tags used in the organization description: | Returns distinct tags in use across tasks the calling API key can see, with usage counts. Sorted by count (desc), then alphabetically. Requires `tasks.read` scope. responses: "200": description: OK content: application/json: schema: type: object properties: success: { type: boolean } data: type: array items: { $ref: "#/components/schemas/TagUsage" } example: success: true data: - { tag: "auth", count: 12 } - { tag: "oauth", count: 7 } - { tag: "kiro", count: 3 } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /api/public/v1/tasks/{id}/links: parameters: - in: path name: id required: true schema: { type: string, format: uuid } get: tags: [Task links] operationId: listTaskLinks deprecated: true summary: "[Deprecated] List commit / PR / URL references on a task" description: | **Deprecated — superseded by the Delivery Evidence API.** Still fully functional; sunset 2026-12-31. Use `GET /api/public/v1/evidence?item_id=...` for new integrations. Requires `tasks.read` scope. Responses carry `Deprecation: true`, `Sunset`, `Link: ; rel="successor-version"` and a `Warning: 299` header. responses: "200": description: OK content: application/json: schema: type: object properties: success: { type: boolean } data: type: array items: { $ref: "#/components/schemas/TaskLink" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } post: tags: [Task links] operationId: createTaskLink deprecated: true summary: "[Deprecated] Attach a commit / PR / URL to a task" description: | **Deprecated — use `POST /api/public/v1/evidence` instead.** Still functional; sunset 2026-12-31. Requires `tasks.update` scope. `type` defaults to `url` when omitted. `url` must be a valid HTTP(S) URL, max 2048 chars. **Mirroring.** Every successful write is mirrored into Delivery Evidence (best effort, idempotent) with this type mapping: | task_links `type` | `evidence_type` | |---|---| | `commit` | `commit` | | `pr` | `pull_request` | | `url` | `external_link` | The mirrored row is created with `status: created`, `visibility: internal` and `verified: false`. Writing a legacy link therefore never produces verified evidence — verification remains an explicit human act via `POST /api/public/v1/evidence/{id}/verify`. requestBody: required: true content: application/json: schema: type: object required: [url] properties: url: { type: string, format: uri, maxLength: 2048 } type: type: string enum: [commit, pr, url] default: url title: { type: string, maxLength: 200, nullable: true } example: type: "pr" url: "https://github.com/example/repo/pull/42" title: "PR #42 — auth callback" responses: "201": description: Created content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/TaskLink" } "400": { $ref: "#/components/responses/ValidationError" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/tasks/{id}/links/{linkId}: parameters: - in: path name: id required: true schema: { type: string, format: uuid } - in: path name: linkId required: true schema: { type: string, format: uuid } delete: tags: [Task links] operationId: deleteTaskLink deprecated: true summary: "[Deprecated] Remove a link from a task" description: | **Deprecated — sunset 2026-12-31.** Requires `tasks.update` scope. Deleting a legacy link does NOT delete its mirrored Delivery Evidence row: evidence is append-only. Use `POST /api/public/v1/evidence/{id}/supersede` to replace evidence. responses: "200": description: Deleted content: application/json: example: success: true data: { deleted: true, id: "uuid" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/tasks/{id}/status-history: parameters: - in: path name: id required: true schema: { type: string, format: uuid } get: tags: [Task history] operationId: getTaskStatusHistory summary: List status change segments for a task description: | Returns segments in ascending order (oldest first). The currently open segment has `exited_at = null` and `duration_seconds = null`. Use this to compute lead time (created → first `Released`) and cycle time (`Under udvikling` → `Released`). Requires `tasks.read` scope. responses: "200": description: OK content: application/json: schema: type: object properties: success: { type: boolean } data: type: array items: { $ref: "#/components/schemas/TaskStatusHistory" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/tasks/{id}/relations: parameters: - in: path name: id required: true schema: { type: string, format: uuid } get: tags: [Task relations] operationId: listTaskRelations summary: List related tasks description: | Returns relations for this task. By default only outgoing relations (this task as source) are returned; use `direction` to include the incoming ones — useful for answering "who is my parent?" from a subtask. Requires `tasks.read` scope. parameters: - in: query name: direction schema: type: string enum: [outgoing, incoming, all] default: outgoing description: Added in v1.14.0. responses: "200": description: OK content: application/json: schema: type: object properties: success: { type: boolean } data: type: array items: { $ref: "#/components/schemas/TaskRelation" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } post: tags: [Task relations] operationId: createTaskRelation summary: Link this task to another task description: | Requires `tasks.update` scope. Both tasks must belong to the same organization. The inverse relation is created automatically: `blocks` ↔ `is_blocked_by`; `relates_to` and `duplicates` are mirrored symmetrically. requestBody: required: true content: application/json: schema: type: object required: [target_item_id, relation_type] properties: target_item_id: { type: string, format: uuid } relation_type: type: string enum: [relates_to, blocks, is_blocked_by, duplicates] example: target_item_id: "9f8e1234-5678-90ab-cdef-1234567890ab" relation_type: "blocks" responses: "201": description: Created content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/TaskRelation" } "400": { $ref: "#/components/responses/ValidationError" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/tasks/{id}/relations/{relationId}: parameters: - in: path name: id required: true schema: { type: string, format: uuid } - in: path name: relationId required: true schema: { type: string, format: uuid } delete: tags: [Task relations] operationId: deleteTaskRelation summary: Remove a relation between two tasks description: | Also removes the mirrored inverse relation. Requires `tasks.update` scope. responses: "200": description: Deleted content: application/json: example: success: true data: { deleted: true, id: "uuid" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/work-sessions: get: tags: [Work sessions] operationId: listWorkSessions summary: List work sessions description: | Requires `work.read`. Internal data — never exposed to customer users. parameters: - in: query name: item_id schema: { type: string, format: uuid } - in: query name: status schema: type: string enum: [active, paused, waiting_for_human, waiting_for_system, waiting_for_external, completed, failed, cancelled] - in: query name: correlation_id schema: { type: string } - in: query name: open description: Only sessions that have not ended. schema: { type: boolean } - in: query name: limit schema: { type: integer, default: 50, maximum: 200 } - in: query name: offset schema: { type: integer, default: 0 } responses: "200": description: A page of work sessions content: application/json: schema: type: object properties: success: { type: boolean } data: type: array items: { $ref: "#/components/schemas/WorkSession" } meta: type: object properties: total: { type: integer } limit: { type: integer } offset: { type: integer } contract_version: { type: string, example: "1.0" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Work sessions] operationId: startWorkSession summary: Start a work session description: | Requires `work.write`. `purpose` must describe the intended outcome, not the activity. Pass `Idempotency-Key` (or `external_session_id`) to make retries safe; a replay returns the original response with `Idempotent-Replay: true`. parameters: - in: header name: Idempotency-Key schema: { type: string } requestBody: required: true content: application/json: schema: type: object required: [item_id, purpose] properties: item_id: { type: string, format: uuid } purpose: { type: string, maxLength: 2000 } actor_type: type: string enum: [human, ai_agent, system, hybrid] default: ai_agent actor_label: { type: string } agent_kind: { type: string } agent_model: { type: string } agent_runtime: { type: string } agent_version: { type: string } correlation_id: { type: string } parent_session_id: { type: string, format: uuid } external_session_id: { type: string } metadata: { type: object, additionalProperties: true } example: item_id: "0f6f1b3e-1111-2222-3333-444455556666" purpose: "Rette fejl i fakturaeksport" actor_type: ai_agent external_session_id: "kiro-2026-08-04-01" responses: "201": description: Session started (clock running) content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/WorkSession" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/work-sessions/{id}: parameters: - in: path name: id required: true schema: { type: string, format: uuid } get: tags: [Work sessions] operationId: getWorkSession summary: Get one work session with time totals description: Requires `work.read`. Use `include=events` to embed the event log. parameters: - in: query name: include schema: { type: string, enum: [events] } responses: "200": description: The work session content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/WorkSession" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/work-sessions/{id}/lifecycle: parameters: - in: path name: id required: true schema: { type: string, format: uuid } post: tags: [Work sessions] operationId: workSessionLifecycle summary: Change work session state description: | Requires `work.write`. The clock only runs while status is `active`; `pause` and every `waiting_*` state stops it. `outcome` is required when completing. Terminal sessions (`completed`, `failed`, `cancelled`) cannot be reopened — start a new session instead. requestBody: required: true content: application/json: schema: type: object required: [action] properties: action: type: string enum: [pause, resume, wait, complete, fail, cancel] wait_for: type: string enum: [human, system, external] default: human outcome: type: string enum: [delivered, partially_delivered, blocked, no_change, failed, cancelled] summary: { type: string } risks: { type: string } message: { type: string } example: action: complete outcome: delivered summary: "Eksport virker igen; regressionstest tilføjet" responses: "200": description: Updated session content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/WorkSession" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": description: Invalid transition for the current status content: application/json: example: success: false error: code: invalid_transition message: "Only an active session can be paused" /api/public/v1/work-sessions/{id}/events: parameters: - in: path name: id required: true schema: { type: string, format: uuid } get: tags: [Work sessions] operationId: listWorkEvents summary: List work events for a session description: Requires `work.read`. Ordered by server-assigned `seq`. parameters: - in: query name: after_seq description: Return only events with a higher seq (incremental polling). schema: { type: integer } - in: query name: limit schema: { type: integer, default: 100, maximum: 500 } responses: "200": description: Event log content: application/json: schema: type: object properties: success: { type: boolean } data: type: array items: { $ref: "#/components/schemas/WorkEvent" } meta: type: object properties: contract_version: { type: string } latest_seq: { type: integer } returned: { type: integer } limit: { type: integer } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } post: tags: [Work sessions] operationId: createWorkEvent summary: Append a work event description: | Requires `work.write`. Append-only: events are never edited or deleted — corrections are new events of type `correction`. Lifecycle events (`session_started`, `session_paused`, `session_resumed`, `session_completed`) are written by `/lifecycle` and rejected here. `seq` and `occurred_at` are assigned by the server. requestBody: required: true content: application/json: schema: type: object required: [event_type] properties: event_type: type: string enum: [waiting_ended, milestone, note, analysis, implementation, review, test_run, build, deployment, evidence_recorded, status_changed, error, correction] message: { type: string, maxLength: 5000 } client_reported_at: type: string format: date-time description: Diagnostic only. The server owns authoritative time. correlation_id: { type: string } idempotency_key: { type: string } metadata: { type: object, additionalProperties: true } example: event_type: milestone message: "Rodårsag fundet: manglende NULL-håndtering i eksportjob" idempotency_key: "kiro-2026-08-04-01-m1" responses: "201": description: Event appended content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/WorkEvent" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": description: Session is terminal, or duplicate idempotency_key content: application/json: example: success: false error: code: session_terminal message: "Session is completed; events are no longer accepted" /api/public/v1/evidence: get: tags: [Delivery evidence] summary: List delivery evidence description: | Scope: `evidence.read`. Filter by task, session, type, status or verification state. Customer-scoped keys only ever see evidence with `visibility` `customer` or `public`. parameters: - { in: query, name: item_id, schema: { type: string, format: uuid } } - { in: query, name: work_session_id, schema: { type: string, format: uuid } } - { in: query, name: evidence_type, schema: { $ref: "#/components/schemas/EvidenceType" } } - { in: query, name: status, schema: { $ref: "#/components/schemas/EvidenceStatus" } } - { in: query, name: verified, schema: { type: boolean } } - { in: query, name: limit, schema: { type: integer, default: 50, maximum: 200 } } - { in: query, name: offset, schema: { type: integer, default: 0 } } responses: "200": description: Evidence list content: application/json: schema: type: object properties: success: { type: boolean } data: type: array items: { $ref: "#/components/schemas/DeliveryEvidence" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Delivery evidence] summary: Register delivery evidence description: | Scope: `evidence.write`. Evidence created through an API key is always `status: registered` / `verified: false` — an agent cannot certify its own work. Send `idempotency_key` so retries never duplicate evidence. Metadata hygiene is enforced server-side: prompts, credentials, tokens and personal data are rejected. requestBody: required: true content: application/json: schema: type: object required: [item_id, evidence_type, title] properties: item_id: { type: string, format: uuid } work_session_id: { type: string, format: uuid, nullable: true } release_id: { type: string, format: uuid, nullable: true } decision_id: { type: string, format: uuid, nullable: true } evidence_type: { $ref: "#/components/schemas/EvidenceType" } visibility: { $ref: "#/components/schemas/EvidenceVisibility" } title: { type: string, maxLength: 300 } summary: { type: string, maxLength: 5000, nullable: true } provider: { type: string, nullable: true, example: github } external_id: { type: string, nullable: true, example: "a1b2c3d" } url: { type: string, format: uri, nullable: true } metadata: { type: object, additionalProperties: true } idempotency_key: { type: string } example: item_id: "0f0d2b4e-0000-4000-8000-000000000001" evidence_type: commit title: "fix(export): håndter NULL i CSV-eksport" provider: github external_id: "a1b2c3d" url: "https://github.com/acme/app/commit/a1b2c3d" idempotency_key: "kiro-2026-08-04-01-e1" responses: "201": description: Evidence registered content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/DeliveryEvidence" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": description: Duplicate idempotency_key with a different payload content: application/json: example: success: false error: code: idempotency_key_reuse message: "Idempotency key already used with a different payload" /api/public/v1/evidence/{id}: parameters: - { in: path, name: id, required: true, schema: { type: string, format: uuid } } get: tags: [Delivery evidence] summary: Get delivery evidence description: "Scope: `evidence.read`." responses: "200": description: Evidence content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/DeliveryEvidence" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } patch: tags: [Delivery evidence] summary: Change evidence visibility description: | Scope: `evidence.write`. `visibility` is the ONLY mutable field. Any other field returns `400 immutable_field` — evidence is append-only. requestBody: required: true content: application/json: schema: type: object required: [visibility] properties: visibility: { $ref: "#/components/schemas/EvidenceVisibility" } responses: "200": description: Visibility updated content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/DeliveryEvidence" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /api/public/v1/evidence/{id}/verify: parameters: - { in: path, name: id, required: true, schema: { type: string, format: uuid } } post: tags: [Delivery evidence] summary: Verify delivery evidence (human act) description: | Scope: `evidence.verify` — must be granted explicitly; it is never implied by `admin.full`. `verified_by` must be an internal user with edit access to the customer, and can never be the creator of the evidence (`403 self_verification_forbidden`). requestBody: required: true content: application/json: schema: type: object required: [verified_by] properties: verified_by: { type: string, format: uuid } responses: "200": description: Evidence verified content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/DeliveryEvidence" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: Missing scope, non-internal verifier, or self-verification content: application/json: example: success: false error: code: self_verification_forbidden message: "Evidence cannot be verified by the actor that created it" "404": { $ref: "#/components/responses/NotFound" } "409": description: Already verified, or evidence is rejected/superseded /api/public/v1/evidence/{id}/reject: parameters: - { in: path, name: id, required: true, schema: { type: string, format: uuid } } post: tags: [Delivery evidence] summary: Reject delivery evidence (human act) description: | Scope: `evidence.verify`. Rejection is terminal and preserves the row — evidence is never deleted. `reason` is required. requestBody: required: true content: application/json: schema: type: object required: [rejected_by, reason] properties: rejected_by: { type: string, format: uuid } reason: { type: string, maxLength: 2000 } responses: "200": description: Evidence rejected content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/DeliveryEvidence" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { description: Evidence is already rejected or superseded } /api/public/v1/evidence/{id}/supersede: parameters: - { in: path, name: id, required: true, schema: { type: string, format: uuid } } post: tags: [Delivery evidence] summary: Supersede evidence with a newer piece description: | Scope: `evidence.write`. Marks this evidence `superseded` and points `superseded_by` at the replacement. Both must belong to the same task. Nothing is deleted — the chain stays auditable. requestBody: required: true content: application/json: schema: type: object required: [superseded_by] properties: superseded_by: { type: string, format: uuid } responses: "200": description: Evidence superseded content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: superseded: { $ref: "#/components/schemas/DeliveryEvidence" } replacement: { $ref: "#/components/schemas/DeliveryEvidence" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { description: Evidence or replacement is rejected/superseded } /api/public/v1/documentation-reviews/pending: get: tags: [Documentation sync] summary: Open documentation reviews for the calling agent integration description: | Scope: `docs.read`. Returns reviews in `PENDING` or `IN_REVIEW` for the integration bound to this API key — never another agent's queue. Customer keys receive `403 actor_forbidden`. A key that is not bound to an active integration receives an empty list. responses: "200": description: Open reviews content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: reviews: type: array items: { $ref: "#/components/schemas/DocSyncReview" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /api/public/v1/documentation-reviews/{id}/start: parameters: - { in: path, name: id, required: true, schema: { type: string, format: uuid } } post: tags: [Documentation sync] summary: Start a documentation review description: | Scope: `docs.sync`. Moves the review from `PENDING` to `IN_REVIEW` with a conditional update — the first writer wins, later callers get `409`. Supports `Idempotency-Key`. Reviews belonging to another integration or workspace return `404`, never `403`. parameters: - in: header name: Idempotency-Key required: false schema: { type: string } responses: "200": description: Review started content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: id: { type: string, format: uuid } status: { type: string, example: IN_REVIEW } started_at: { type: string, format: date-time } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { description: Review already started, or idempotency conflict } /api/public/v1/documentation-reviews/{id}/assessment: parameters: - { in: path, name: id, required: true, schema: { type: string, format: uuid } } post: tags: [Documentation sync] summary: Submit an assessment for a documentation review description: | Scope: `docs.sync`. Moves the review from `IN_REVIEW` to `ASSESSED`. The agent reports what it read and what it changed; it never marks itself synchronised — an internal reviewer must accept the assessment before the integration reaches `synchronized`. Supports `Idempotency-Key`. Reviews belonging to another integration or workspace return `404`, never `403`. parameters: - in: header name: Idempotency-Key required: false schema: { type: string } requestBody: required: true content: application/json: schema: type: object required: [summary] properties: summary: type: string minLength: 10 maxLength: 4000 description: What the release means for this agent. actions: type: array maxItems: 50 items: type: object required: [kind, description] properties: kind: type: string enum: [applied, planned, no_change_needed, blocked] description: { type: string, maxLength: 1000 } reference: { type: string, maxLength: 500 } responses: "200": description: Assessment recorded, awaiting human decision content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: id: { type: string, format: uuid } status: { type: string, example: ASSESSED } awaiting_decision: { type: boolean } message: { type: string } "400": { description: Invalid summary or actions } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { description: Review is not IN_REVIEW, or idempotency conflict } /api/public/v1/docs/sources: get: tags: [Documentation sync] summary: List registered documentation sources description: | Scope: `docs.read`. Workspace-scoped active sources with the current immutable version hash and repository revision. responses: "200": description: Documentation sources content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: sources: type: array items: { $ref: "#/components/schemas/DocSource" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": { description: API key is not bound to an active agent integration } /api/public/v1/docs/releases: get: tags: [Documentation sync] summary: List published documentation releases description: | Scope: `docs.read`. Published releases only, newest `release_no` first. `release_no` is the primary identity; `api_version` is a nullable relation and may be absent for doc-only releases. parameters: - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } - { in: query, name: release_no, schema: { type: integer } } responses: "200": description: Documentation releases content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: releases: type: array items: { $ref: "#/components/schemas/DocRelease" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": { description: API key is not bound to an active agent integration } /api/public/v1/docs/agent-documents: get: tags: [Documentation sync] summary: List the agent's own document references description: | Scope: `docs.read`. Returns only references owned by the calling integration. responses: "200": description: Agent document references content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: documents: type: array items: { $ref: "#/components/schemas/AgentDocumentRef" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Documentation sync] summary: Register or refresh an agent document reference description: | Scope: `docs.sync`. Registers a steering file, system prompt or client config the agent uses. `is_confirmed` is server-owned and always stored as `false`: an agent can never confirm its own reference. requestBody: required: true content: application/json: schema: type: object required: [title] properties: title: { type: string, maxLength: 300 } document_type: type: string enum: [steering, system_prompt, client_config, other] default: other provider: type: string enum: [github, local, external] default: external repository: { type: string, nullable: true } path: { type: string, nullable: true } branch: { type: string, nullable: true } external_url: { type: string, nullable: true } last_known_revision: { type: string, nullable: true } last_known_hash: { type: string, nullable: true } responses: "201": description: Reference registered content: application/json: schema: type: object properties: success: { type: boolean } data: { $ref: "#/components/schemas/AgentDocumentRef" } "200": { description: Existing reference returned unchanged } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": { description: API key is not bound to an active agent integration } /api/public/v1/memory/search: get: tags: [Development memory] summary: Search the organisation's development memory description: | Scope: `memory.read` (explicit — never implied by `admin.full`). Deterministic PostgreSQL full-text retrieval across items, decisions, comments, delivery evidence, work sessions and — with `docs.read` — documentation. Visibility is derived from the API key's actor class, never from a request parameter: customer-class actors never match on internal text and never receive internal rows. See `docs/NODEOS_DEVELOPMENT_MEMORY.md`. parameters: - in: query name: q required: true schema: { type: string, minLength: 2, maxLength: 200 } description: Free-text query. - in: query name: source_type schema: type: array items: type: string enum: [item, decision, comment, evidence, work_session, documentation] style: form explode: true description: Restrict to specific source types. - in: query name: area_id schema: { type: string, format: uuid } description: Restrict to one area; must be within the key's areas. - in: query name: item_id schema: { type: string, format: uuid } description: Restrict to the history of one task. - in: query name: limit schema: { type: integer, minimum: 1, maximum: 50, default: 20 } responses: "200": description: Ranked memory records content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: query: { type: string } results: type: array items: { $ref: "#/components/schemas/MemoryResult" } meta: type: object properties: limit: { type: integer } result_count: { type: integer } visibility: type: object properties: actor_class: { type: string } includes_internal: { type: boolean } includes_documentation: { type: boolean } retrieval: type: object properties: mode: { type: string, example: deterministic_fts } version: { type: string, example: "1.0" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" }