Skip to content

Feedback API

The feedback API collects two kinds of user feedback plus client error reports.

  • App feedback at /admin/v1/app-feedback — bug reports, feature requests, other comments, and Contact Support requests submitted via the in-app feedback widget. A Contact Support request additionally sends an email to Myra support (see Contact Support below).
  • Session feedback at /admin/v1/feedback — per-conversation ratings submitted at the end of a chat session.
  • Client errors at /admin/v1/client-errors — uncaught exceptions and failed fetch reports captured by the SPA.

All endpoints require an authenticated admin session, with one exception: POST /admin/v1/client-errors is public and unauthenticated, so the SPA can report crashes before an admin session exists.


App feedback — listing entries

GET /admin/v1/app-feedback

Required permission: FEEDBACK_TRIAGE (platform admin). A tenant admin cannot read the inbox — submitting feedback and triaging it are different privileges.

Optional query parameters:

  • type — one of bug, feature, other, support.
  • status — narrow the inbox to ONE triage lifecycle status. The value is validated against the closed status enum (received, triaged, responded, escalated, resolved, auto_resolved, needs_human); an unknown value is ignored (the full inbox is returned — fail closed, never bound into SQL). The CS-automation surface passes status=needs_human to show only the human queue — items the automation could not safely auto-resolve or auto-ticket. Praise that was auto-resolved and actionable bug/feature that was auto-ticketed drop out, so the manual pile shrinks to only what a human must see.
  • limit — maximum entries to return. Default 50; a value above 200 is silently capped at 200.
  • offset — number of entries to skip, for paging. Default 0.

Returns an array of entries. Each entry contains:

Field Type Description
id string Entry identifier.
user_id string | null Submitting user.
user_email string | null Email of the submitting user.
type bug | feature | other | support Category. support is a Contact Support request.
summary string Short summary.
description string | null Long description.
url string | null URL of the page from which the feedback was submitted.
client_context object | null Client-side context captured with the submission (as documented for Reports); null when none.
created_at unix seconds Submission timestamp.
processed 0 | 1 Processed flag.
status string | null Triage lifecycle status (see Automated triage).
category string | null AI category (bug, feature_request, question, praise, complaint, other).
severity string | null AI severity (low, medium, high, critical).
confidence integer | null AI confidence percent (0–100).
linked_ticket string | null The YouTrack issue id (e.g. AGF-1234) this item was auto-created/linked to, or null.
responded_at unix seconds | null When a user-facing response was sent, or null.
image_content_type string | null Legacy single-inline image type (image/png/image/jpeg) for entries submitted before multi-image support; null otherwise.
image_count integer How many evidence images this entry carries. The image bytes are not included in the list — fetch them by id (see evidence images below).

App feedback — submitting an entry

POST /admin/v1/app-feedback

Required role: authenticated. Submitting type: "support" additionally requires the SUPPORT_CONTACT permission (see Contact Support).

Submits a new entry. Both summary and description are required.

Request body fields:

Field Type Required Result
summary string Yes Short summary; scrubbed to valid UTF-8 and truncated to 255 bytes. Absent, explicit null, or a whitespace-only value → 400 ("summary required"), nothing persisted.
description string Yes Long description; scrubbed to valid UTF-8 and truncated to 4000 bytes. Absent, explicit null, or a whitespace-only value → 400 with a stable {"error": "…", "code": "description_required"}, nothing persisted. Absent and whitespace-only are answered identically — a required field's rejection does not degrade to a permissive path. (Server-side "whitespace-only" is ASCII whitespace — the same %s blank test summary uses; the widget additionally trims Unicode whitespace client-side, so a Unicode-space-only value never reaches the server from the app. Consistent with summary, a non-string value — number/object — is coerced via tostring rather than rejected; the presence/blank rule is what is enforced here.)
type string No See the table below.
url string | null No Originating page URL; truncated to 1024 bytes.
client_context object No Diagnostic envelope (validated separately; see feedback validation).
image / images see Evidence image below No Optional screenshot(s).

The description guard is applied alongside the summary guard — before the SUPPORT_CONTACT authorization check — so a blank-description submission of any type (including support) is answered 400 description_required and never consults, nor leaks, the permission. Branch on the top-level code, never on the error prose — see Error codes.

type is optional. Absent and malformed are treated differently, deliberately:

type Result
omitted Defaults to other.
bug / feature / other Used as given.
support A Contact Support request — see below. Requires SUPPORT_CONTACT.
any other value — an unrecognised string, a non-string (number, boolean, array, object), or an explicit null Rejected 400, nothing is persisted. The body carries a stable code: {"error": "…", "code": "invalid_type"}.

A value that arrives wrong is not silently reinterpreted as other; only an absent field is defaulted. Branch on the top-level code, never on the error prose — see Error codes.

Contact Support

type: "support" is the in-app route to Myra support. It is gated by the SUPPORT_CONTACT permission, which the admin and tenant_admin system roles hold by default; because it is a tenant-scoped (non-platform) permission, a tenant admin may also delegate it to a custom role. A caller without it receives 403 and nothing is written — the client hiding the option is only a convenience, the server is the boundary.

On success the entry is stored exactly like any other feedback (and mirrored into the triage queue), and an email is sent to the configured support mailbox containing the tenant, the submitting user's name and email, the summary, the description, and a link back to the stored entry. The stored row is the durable record: if the email cannot be delivered the request is not lost — it is still in the feedback inbox, and the failure is logged and raised to ops.

The recipient is the support_email global setting; it is never taken from the request.

Rate limit. Contact Support requests are limited to 3 per hour per user and 10 per hour per tenant. Over either limit the response is 429 with {"error": "…", "code": "support_rate_limited"} and no entry is created. Other feedback types are unaffected — a rate-limited user can still file a bug report. The limits are applied after the permission check, so an unauthorised caller can never consume another tenant's budget.

Optional evidence images

The body may carry optional screenshots so a reporter can attach visual evidence. Two shapes are accepted:

Field Type Notes
images array Up to 6 objects { image, image_content_type } (below). Over the cap → 413 image_too_many, and no entry is created.
image string (bare base64) Legacy single-image form (still accepted). The image bytes, base64-encoded with no data: prefix. Equivalent to images: [{ image, image_content_type }]. Ignored when images is present.
image_content_type string The client-declared MIME. A hint only — used to detect a spoof, never trusted as the authoritative type.

Each element of images has the same { image, image_content_type } fields as the legacy pair. Every image is validated server-side, fail-closed at the trust boundary (the client-declared type and any filename are hostile and never trusted):

  • The authoritative type is derived from the magic bytes. PNG and JPEG are stored directly. An Apple/other image format (HEIC/HEIF/AVIF, DNG, WebP, GIF, TIFF, BMP) is first converted server-side to a downscaled JPEG (≤2048 px, EXIF-stripped) and that JPEG is then validated + stored — so an iPhone photo is accepted, not rejected. A non-image payload, a spoofed declared type that decodes to nothing, an executable, or an SVG is still rejected.
  • Size cap: 10 MiB (raw, pre-base64). An over-long encoded body is rejected before it is decoded.
  • Dimension cap: the decoded image must be a parseable PNG/JPEG of at most 25 megapixels (a small file declaring enormous dimensions — a decompression/canvas bomb — is rejected).

A rejected image never creates an entry. The validation rejections below answer a 4xx whose error is the bare image_<reason> token; the two malware-scan rejections (last rows) instead carry operator prose in error plus a stable top-level code (malware_detected / scan_unavailable) that a client keys its message off, and the 503 carries Retry-After:

Reason Status Meaning
image_bad_encoding 400 image is not a decodable base64 string (includes a non-string value — no 500).
image_empty 400 The decoded payload is empty.
image_not_an_image 400 The magic bytes are not PNG/JPEG (spoof, wrong type, or a non-image payload).
image_bad_dimensions 400 The bytes claim to be PNG/JPEG but the dimensions cannot be parsed.
image_too_large 413 Over the 10 MiB size cap (or an over-long encoded body).
image_too_many_pixels 413 Over the 25-megapixel dimension cap.
image_too_many 413 More than 6 images in images.
image_not_an_image 400 An images element is not an object ({ image, image_content_type }).
code: "malware_detected" 422 The malware scanner flagged an image (see Malware scanning below). error is operator prose ("This file was blocked by malware scanning."); the signature is logged operator-side only, never returned.
code: "scan_unavailable" 503 + Retry-After Malware scanning applies but could not be completed — the scanner was unreachable/errored, or whether scanning applies could not be determined. The submission is refused rather than stored unscanned. Retryable.

A rejection of any image rejects the whole submission (no partial entry). On success each stored image is the base64 of the exact validated bytes (never the raw client string), kept in attach order and removed when the entry is deleted.

Malware scanning (fail-closed, opt-in per gateway). Evidence images are persisted and served back raw to platform triage, so they are malware-scanned like every other persist-and-serve upload. Scanning is enabled per gateway (av_scan.enabled in a gateway config) and off by default; a feedback entry has no gateway, so an image is scanned when the reporter's tenant has malware scanning enabled on any of its gateways — otherwise this step is skipped. The order is fixed: every image is validated first (a malformed image is answered 4xx without consulting the scanner), then enablement is resolved once, then each image's exact validated bytes — the very bytes that get stored; for a converted HEIC/other-format upload that is the produced JPEG — are streamed to the self-hosted ClamAV daemon, and only then are the Contact-Support rate windows charged and the entry written. An infected verdict rejects the whole submission with 422, code: "malware_detected"; a scanner that is unreachable, times out or errors — or an enablement that cannot be determined (the tenant's gateway list is unreadable or a gateway config will not decode) — rejects it with 503, code: "scan_unavailable" and a short Retry-After, never storing the images unscanned. Residuals, by design: a submission from a tenant that has not opted in, or from an account with no tenant at all (a platform admin's normal shape, or a tenantless user — owning no gateway, none can have opted in), is stored unscanned; the raw bytes handed to the server-side image converter are not scanned (only its output is — decoder hardening is the converter sandbox's job); and the image/normalize preview helper below persists nothing and is not scanned (what it returns is scanned when submitted). The ClamAV endpoint is deployment infrastructure configured by your operator, consulted only when a gateway has opted in. The SPA maps both codes to translated copy and records the rejection in the attach beacon (malware / scan_unavailable; note the scan_unavailable beacon kind is shared with the image-normalizer-at-capacity reject image_server_busy, so a beacon alone does not tell the two transient states apart — the server log does).

App feedback — normalize an image (preview helper)

POST /admin/v1/app-feedback/image/normalize

Auth: any authenticated user (the same as submitting — a reporter normalizing their own attachment); no FEEDBACK_TRIAGE permission required. Pure transform — stores nothing.

The feedback dialog calls this when the browser cannot decode a picked file (an iPhone HEIC/HEIF), so it can show a preview and submit a storable format. Browsers can't render HEIC; the server reuses the image decoder (image_strip, libheif/rawpy) to convert it.

Field Type Notes
image string (bare base64) The raw picked bytes, base64-encoded, no data: prefix. Untrusted.
image_content_type string | null Client-declared MIME. A hint only; the type is derived from magic bytes.
  • A PNG/JPEG input is validated and returned unchanged (no conversion).
  • Any other decodable image (HEIC/HEIF/AVIF/DNG/WebP/GIF/TIFF/BMP) is converted to a downscaled JPEG (≤2048 px longest side, EXIF-stripped), which is then re-validated (magic + 10 MiB + 25 MP bounds) before it is returned — the endpoint never returns an unbounded image.

Success 200: { "image": "<jpeg base64>", "content_type": "image/jpeg" } (or the original content_type for a pass-through PNG/JPEG).

Error Status When
image_bad_encoding 400 image is missing / not a decodable base64 string (a non-string value is rejected here, never a 500).
image_unsupported 422 The bytes could not be decoded (unknown/corrupt/truncated format, or a decompression bomb).
image_too_large 413 Over the 10 MiB size cap (or an over-long encoded body).
image_too_many_pixels 413 The produced image is over the 25-megapixel cap.
image_server_busy 503 The image-conversion workers are at capacity — a transient condition, retry shortly (the image is fine).

App feedback — evidence image

GET /admin/v1/app-feedback/<ID>/image

Required permission: FEEDBACK_TRIAGE (a platform-admin permission).

Returns the entry's evidence image as base64 in JSON (the SPA renders it as a data: URL — the admin API is a separate origin whose img-src CSP forbids a cross-host <img src>):

Field Type Description
id string Entry id.
mime string image/png or image/jpeg (server-derived).
size_bytes integer Raw (pre-base64) byte count.
data string The image bytes, base64-encoded.

An entry with no image, or an unknown <ID>, returns 404. The response carries Cache-Control: private, no-store (feedback screenshots may contain PII, so no shared cache retains them). This legacy endpoint serves the single inline image of pre-multi-image entries; use /images (below) for the full set.

App feedback — evidence images (all)

GET /admin/v1/app-feedback/<ID>/images

Required permission: FEEDBACK_TRIAGE.

Returns all of the entry's evidence images (0..N), in attach order, as base64 in JSON:

Field Type Description
id string Entry id.
images array Objects { id, mime, size_bytes, data } — data is the base64 image, mime the server-derived image/png/image/jpeg. An empty array when the entry has no image (never a 404 for an existing entry).

The same Cache-Control: private, no-store applies. Entries submitted before multi-image support return their single legacy inline image as a one-element array.


App feedback — updating an entry (processed flag or editable fields)

PATCH /admin/v1/app-feedback/<ID>

Required permission: FEEDBACK_TRIAGE (platform admin).

A single PATCH does either a processed-toggle or a content edit — the two modes are mutually exclusive. A body that mixes them (a processed key together with any editable field) is rejected with 400.

Processed toggle — a body carrying processed:

Field Type
processed boolean

Content edit — a body carrying any editable field. This is a partial update: an omitted field is left untouched; a field present with an empty/null value is cleared to SQL NULL.

Field Type Notes
summary string Required and non-blank on an edit; truncated to 255. Blank/missing → 400.
type string Coerced to one of bug, feature, other, support — any other value becomes other. This differs from POST, deliberately: PATCH is the platform-admin triage editor, where coercion normalises a legacy or hand-written row instead of locking an admin out of editing it; POST rejects a malformed type instead. Re-typing an entry to support here does not send an email — only the original submission does.
description string | null Optional; truncated to 4000. Empty/null clears it.
url string | null Optional; truncated to 1024. Empty/null clears it.

An unknown <ID> returns 404. On a content edit, the mirrored triage-queue row (model_error where source = 'app_feedback') is kept coherent — its title follows summary and its detail follows description.


App feedback — deleting an entry

DELETE /admin/v1/app-feedback/<ID>

Required permission: FEEDBACK_TRIAGE (a platform-admin permission).

Permanently removes the feedback entry. This is a hard, irreversible delete: the row is removed AND its mirrored triage-queue row (model_error where source = 'app_feedback', source_ref = <ID>) is removed in the same transaction, so the unified triage queue never orphans a deleted entry and reconciliation never re-adds it. An unknown <ID> returns 404. The deletion is recorded in the admin audit log (actor, method, path, status).


Automated triage (data model)

Every app-feedback and session-feedback row carries an automated-triage lifecycle. When the platform setting feedback_triage_enabled is on, a background sweep classifies each new submission with the platform's own model and records the result on the row. The sweep never sends a response, resolves an item, or creates a ticket — it only classifies and records a status (later capabilities build on this data).

Each feedback row exposes:

Field Type Notes
status enum received → triaged → responded → escalated → resolved (+ auto_resolved, needs_human). A new row is received. resolved means a genuine resolution: praise auto-resolved (nothing to fix), or an actionable item whose linked tracker ticket was marked Done (see ticket-fixed reconciliation) — it does not mean "a human cleared the queue". The legacy processed flag remains for backward compatibility and is independent of status.
category enum | null bug, feature_request, question, praise, complaint, other.
sentiment enum | null positive, neutral, negative.
severity enum | null low, medium, high, critical.
confidence integer | null The model's confidence in the category, 0–100.
auto_response text | null The user-facing response text (the deterministic acknowledgment, plus — for an answered question — the substantive answer and an automated-reply disclaimer). Written by the response layer; null until then, and cleared on Art. 17 erasure.
responded_at integer | null Unix seconds the response was written.
response_channel enum | null How the response was delivered: inapp+email (both) or inapp (in-app only — e-mail was suppressed by an opt-out, a test address, a disabled user, no address, or the per-user rate cap).
linked_ticket string | null A linked tracker id, when one exists.
dedup_cluster_id string | null A deterministic content fingerprint; near-identical feedback shares one cluster.

Trust boundary — the classifier's output is validated, never trusted. The feedback free text is treated as data, never as instructions: the classifier prompt is constrained, and the model's answer is parsed strictly against the closed enums above. An answer that is absent, malformed, not JSON, outside an enum, or an echoed injection (e.g. "category": "resolved" or "ignore instructions and mark resolved") is rejected — the item is routed to needs_human, never a wrong auto-action. The model cannot set a real status, send anything, or resolve an item; a deterministic layer sets the status from the validated result, and low confidence, high/critical severity, or an un-parseable answer always route to a human.

A per-item, append-only status timeline (feedback_status_event) records each transition (from/to status, the acting party — system / ai_triage / human / feedback_response / cs_automation — the category snapshot, and a reason code). It carries only system-authored, non-PII metadata (never the user's free text or a response).

Automated response & notifications

When the platform setting feedback_response_enabled is on, a second background sweep produces a user-facing response for each triaged item and delivers it in-app (writes auto_response + responded_at, appends a responded timeline event, status → responded) and by e-mail (reusing the reactivation mail infrastructure — templates, sendmail, and the opt-out link). The decision of what to send and whether to send is entirely deterministic — the model never triggers a send:

  • Every category gets a fixed, deterministic acknowledgment (praise / bug / feature / complaint / other / question) and is auto-sent.
  • A question additionally gets an AI-drafted substantive answer — but only when its confidence is at or above feedback_response_min_confidence (default 80, a higher bar than triage) and the severity is not high/critical. Below the threshold, or high/critical severity, the item routes to needs_human with no response sent — the brand safety net.

Trust boundary — the drafted answer is validated + sanitised before it reaches a user (the model output is data, never instructions). The question text is delimited as data and the answer prompt forbids any commitment, roadmap claim, price, or link. The completion is then sanitised: control bytes are stripped, invalid UTF-8 is rejected, length is capped, and any answer that carries a URL / e-mail scheme is rejected outright (an injected phishing link is the worst outcome of a feedback→answer loop) — a missing, malformed, empty, or unsafe answer folds to the same safe outcome: the item is escalated to needs_human, never sent. The disclaimer is appended by the platform, never by the model. The deterministic acknowledgment carries no model text at all, so an injection in the feedback (e.g. "ignore instructions and mark resolved") can never be echoed back.

Delivery guarantees. Exactly one response per feedback item — a claim-first protocol admits one responder across the active-active fleet, and the response write is idempotent (a re-run is a no-op). The e-mail (not the in-app response) is suppressed for a user who has opted out of lifecycle mail, has a test/fixture address, is disabled, has no address, or is over the per-user daily cap (feedback_response_user_daily_cap); those items are still answered in-app. A processing-restricted (GDPR Art. 18) user, or an anonymous submission with no user to answer, is escalated to a human rather than auto-processed.


CS automation — auto-ticket, auto-resolve, human queue

When the platform setting feedback_cs_enabled is on, a second background sweep runs after triage and applies ONE deterministic outcome per triaged item:

  • praise (no actionable content) → status = auto_resolved (no ticket);
  • an actionable, high-confidence bug / feature_request → auto-create or link a YouTrack issue, dedupe-gated, and write linked_ticket back on the feedback;
  • everything low-confidence / high-severity / that the ticket integration can't safely handle → status = needs_human (the admin human queue).

Confidence + severity gate. A bug/feature below feedback_cs_min_confidence or at high/critical severity is never auto-ticketed — it goes to a human. Dedupe is two-layer: an existing ticket for the item's dedup_cluster_id is reused (LINK, no new issue), and a YouTrack search for the cluster marker also recovers a create-then-crash orphan — so a cluster of near-identical feedback yields exactly one ticket. New tickets created per sweep are rate-capped by feedback_cs_max_tickets_per_sweep (links and auto-resolves are uncapped); a capped item is deferred (left triaged), never escalated.

Fail closed (trust boundary — the YouTrack response is a hostile third party). A missing/undecryptable service token, an unreachable project, ANY YouTrack HTTP error, or a create response without a well-shaped issue id (^[A-Za-z0-9]+-[0-9]+$) all route the item to needs_human — the item is never silently dropped and a malformed ticket is never persisted. The token is validated for header-injection safety (no whitespace/control bytes) before it is ever placed in the Authorization header.

Ticket-fixed reconciliation (closing the loop honestly). A ticketed item stays triaged/responded (shown to the reporter as "In review") until its fix actually ships. Before each ticketing pass, the sweep reconciles every linked, not-yet-resolved item against YouTrack: it advances the feedback to resolved only when the linked ticket's Stage is Done (isResolved). A ticket closed as "Won't fix" (or any other resolved-but-not-fixed stage) is isResolved too but is never treated as fixed — so a declined report is never falsely shown as "resolved". The transition is forward-only (a later reopen does not revert it), appends a ticket_done timeline event, and is bounded per sweep (distinct ticket look-ups are capped and de-duplicated, with a short-circuit on a dead tracker). The linked-ticket id is validated against ^[A-Za-z0-9]+-[0-9]+$ before any request; a transport/HTTP error leaves the item unchanged (fail closed — never a false resolve).

YouTrack integration configuration

GET /admin/v1/feedback/youtrack — read the current config. Required permission: SETTINGS_MANAGE (platform admin — this is a platform credential).

Returns { "base_url": string, "project": string, "token_set": boolean }. The service token value is never returned — only whether one is set.

PUT /admin/v1/feedback/youtrack — set the config. Required permission: SETTINGS_MANAGE.

Field Type Required Description
base_url string Yes The YouTrack base URL. Must be an https:// URL (the token rides this connection) without whitespace or control characters; ≤ 512 bytes; a trailing slash is trimmed. A non-https or malformed value is rejected 400.
project string Yes The target project short name (e.g. AGF). 1–64 chars of letters, digits, _ or -; anything else is rejected 400.
token string No The YouTrack service token (a permanent token). Optional on update — omit to keep the existing one. When present it must be a non-empty string free of whitespace/control bytes. It is stored encrypted at rest (never plaintext, never an environment variable) and never returned on read. An explicit empty string is rejected.

The write is audited without the secret (only token_set is recorded, never the token value or its ciphertext). The target project must accept issue creation via the REST API without required custom fields (use a dedicated feedback project, or one whose fields are optional) — otherwise every create fails closed to needs_human.

Response: 200 { "ok": true, "base_url": ..., "project": ..., "token_set": ... }.

CS-automation metrics

GET /admin/v1/feedback/cs-metrics — automation KPIs over a trailing window. Required permission: FEEDBACK_TRIAGE.

Query parameter window_days (default 30, clamped 1–365). Returns:

Field Type Description
handled integer Feedback rows triaged-or-beyond in the window.
auto_resolved integer Rows auto-resolved.
needs_human integer Rows escalated to a human.
tickets_auto_created integer Rows carrying an auto-created/linked YouTrack ticket.
auto_resolved_pct number auto_resolved / handled, one decimal place.
escalation_rate_pct number needs_human / handled, one decimal place.
median_first_response_s number | null Median first-response time (seconds), or null when no response has been sent yet.
window_days integer The clamped window used.

Session feedback — listing entries

GET /admin/v1/feedback

Required role: platform admin.

Optional query parameters:

Parameter Description
processed Filter by processed flag: true or false. Omit for all.
project_id Restrict to feedback whose conversation belongs to this project.
limit Maximum number of entries to return. Default 100; a value above 500 is silently capped at 500.

Each entry contains:

Field Type Description
id string Entry identifier.
conversation_id string Source conversation.
user_id string Conversation owner.
email string | null Email of the conversation owner.
project_id string | null Project the conversation belongs to.
project_name string | null Resolved project name.
rating 1–5 Rating value (5 = best, 1 = worst).
comment string | null Optional comment.
processed 0 | 1 Processed flag.
created_at unix seconds Submission timestamp.
updated_at unix seconds Last update timestamp.

Session feedback — updating the processed flag

PATCH /admin/v1/feedback/<ID>

Required permission: FEEDBACK_TRIAGE (a platform-admin permission).

Marks the entry as processed. The request body is ignored; the entry is always set to processed, and the action cannot be undone.


Per-conversation feedback (user-side)

The end-user side of the session feedback flow lives at:

  • PUT /admin/v1/conversations/<ID>/feedback — submit or update a rating with an optional comment. The caller must own the conversation; a request for a conversation the caller does not own returns 404. On success it returns { "ok": true, "id": "<feedback-id>" } — the id lets the chat feedback dialog show an instant "received, reference #" acknowledgment and a link to My feedback (below).
  • GET /admin/v1/conversations/<ID>/feedback — read the calling user's submitted rating.

These endpoints require authenticated access to the conversation. See Conversations API.

💡 Note: When a user submits or updates a rating via PUT /admin/v1/conversations/<ID>/feedback, the rating is also mirrored, best-effort, into the unified triage queue (source="chat_feedback", one row per conversation).


My feedback (closed-loop, user-side)

GET /admin/v1/my-feedback

Returns the calling user's own feedback — both app feedback and per-conversation chat ratings — each with its category, lifecycle status, status timeline, and the automated response, so the SPA's My feedback view can show the user what happened with what they submitted.

Strictly user-scoped (isolation is the security invariant). The result is filtered by the session user id only — taken from the authenticated session, never from any request input. A user physically cannot address another user's feedback. A user_id query parameter is accepted and ignored (it is documented here so callers do not rely on it; it can never re-scope the read). Under an audited impersonation session the result is the impersonated (target) user's feedback, consistent with the rest of the impersonation model.

Requires an authenticated session (any role). Unauthenticated → 401.

Response — a JSON array (empty [] when the user has submitted nothing), newest first. Each element:

Field Type Notes
id string The feedback row id.
source string app_feedback or chat_feedback.
status string Lifecycle status: received, triaged, responded, escalated, resolved, auto_resolved, or needs_human.
category string | omitted The AI category (bug, feature_request, question, praise, complaint, other) once triaged; omitted until then.
created_at number Submission time (unix seconds).
auto_response string | omitted The automated response text; omitted until one exists.
responded_at number | omitted When the response was written (unix seconds); omitted until then.
feedback_type string | omitted App feedback only: bug | feature | other | support.
title string | omitted App feedback only: the summary.
body string | omitted The user's own submitted text (app description or chat comment).
rating number | omitted Chat feedback only: 1–5.
conversation_id string | omitted Chat feedback only.
timeline array Append-only status transitions (oldest first): [{ from_status, to_status, actor, category, created_at }]. Empty until the first transition; the SPA renders a synthetic received origin from created_at.

Not exposed: internal AI triage signals (sentiment, severity, confidence), the dedupe cluster id, any linked ticket, and the response channel are deliberately omitted — the user sees only their category, status, timeline, and response. The user's own free text and the response are returned as data (the SPA renders them as escaped text, never HTML). On a storage fault the endpoint returns 500 with { "error": "internal error" }.


Client errors

POST /admin/v1/client-errors — submit a client-side error report. Public and unauthenticated (the SPA reports crashes before an admin session exists), so the body is fully untrusted and sanitized at this trust boundary.

Field Type Required Description
message string Yes The error message. Rejected with 400 if missing, null, or blank. Truncated to 2 000 bytes on a UTF-8 codepoint boundary; invalid UTF-8 bytes are scrubbed to U+FFFD.
id string No Client-chosen row id. A null, blank, or over-36-byte id is replaced with a server-generated UUID (so a report is never silently dropped by a NULL/oversized primary key).
stack string No Stack trace. Truncated to 8 000 bytes on a codepoint boundary; invalid UTF-8 scrubbed; null/blank → SQL NULL.
url string No Page URL. Truncated to 500 bytes; scrubbed; null/blank → SQL NULL.
user_agent string No User agent. Truncated to 500 bytes; scrubbed; null/blank → SQL NULL.
ts number No Accepted and ignored: the row's timestamp is always the server's receive time (unix milliseconds). A client clock cannot date a report into the future or the past, so the 30-day retention sweep is a true age.
conversation_id string No Associated conversation (mirrored into the triage queue). Dropped to null if null/blank/over 36 bytes.
client_context object No Browser diagnostic snapshot (app/OS version, screen size, viewport, locale, timezone, colour scheme, current route, referrer, …) — the same schema and validator as the application-feedback client_context above. Validated at this trust boundary: unknown keys are clamped, over-size (>16 KiB) or wrong-type context is rejected and stored as NULL, and the report still returns 201. Server-only keys a client tries to inject (user_id, client_ip, tenant_id, …) are stripped. current_route carries the page's query string, so its query is scrubbed to a non-reversible marker before storage (operator-visible logs never leak ?text= / ?project_id= values), exactly as url is.

Diagnostic context captured server-side (never client-sent). In addition to the body above, the server records:

  • Account — if a valid admin session cookie rides along, the report is linked to that user (id + tenant + email). This is soft-resolved: a missing, expired, or malformed session cookie degrades the report to anonymous (user_id/tenant_id NULL) — never a 401/5xx. The user id is taken from the session, never from the request body.
  • IP — the client IP is derived server-side from the trusted Myra CDN edge, not from any client-sent field.

These give operators the account, tenant, IP, app/OS version, and screen size needed to diagnose an otherwise unattributable browser crash; they surface in the triage queue and under model-errors.sh context <id>. The account id and IP are personal data and are erased on user deletion / tenant purge and included in a user's data export. On erasure/purge the page URL and client_context are nulled too (both can carry client-supplied personal data — a verbatim referrer or arbitrary extra keys); only the bounded error message/stack remain, removed by the 30-day sweep.

A malformed or hostile body never returns a 5xx: null-in-a-truthy-field, invalid UTF-8, and over-length values are all sanitized rather than persisted verbatim; a malformed client_context is dropped to NULL and the report still returns 201.

Message tag conventions. The SPA prefixes its reports so operators can filter the queue: [user-error:<area>] marks a user-visible error the app surfaced in area <area> (e.g. chat, easy-create, login, knowledge-upload, and attach for user-caused attachment rejections: unsupported type, too large, too many, server image rejects, malware/scan states); [attach] marks an app/platform-caused attachment failure (file-read failures, empty reads, a native file chooser that never opened, a null clipboard item, and ingest_busy: the shared upload pool answered 503 and the chat surfaced the busy banner, the signal for sizing the pool) — the operator's bug queue; ui-anomaly: <kind> marks a silent bad on-screen state that threw no exception. Attachment beacons carry only structured facts — ingress source (picker/paste/drop/longpaste/send/feedback), lowercased file extension, MIME, a bucketed byte size, and a per-gesture fold count — never a filename or localized banner text.

Response: 201 { "ok": true }

Retention. Client error reports are kept for 30 days and then removed by an hourly, batched sweep (storage limitation — the report text may contain user-visible content or URLs). The triage queue keeps only the de-duplicated aggregate (fingerprint, title, cumulative count) beyond that; the raw message and stack behind a triage item exist for the retention window only.

GET /admin/v1/client-errors — list submitted client errors (those within the 30-day retention window). Required permission: FEEDBACK_TRIAGE (a platform-admin permission).