Team and AI API

Authenticated analysis, contact management and AI, with request schemas, response examples and streaming guidance.

Before you make a request

These are the internal beta routes used by the signed-in web app. Sign in on the same origin before running browser examples. The session is read from cookies, and the current role and assigned cities are loaded from the database. Analyst access means analyst, admin or rizicon_admin; contact management requires rizicon_admin.

Response examples contain illustrative data. Replace example IDs with records you can access. Send application/json for JSON bodies. These routes are not a versioned external contract; agree on a supported integration before automating them.

Read management analysis

GET/api/manage-analysis
Analyst, admin or Rizicon adminAnalysisPaginatedCity scope

Returns a page of analysis results and a summary for the caller’s assigned cities. Rizicon admins have global city access. Successful responses use { items, total, summary } and Cache-Control: private, no-store. Summary totals describe the relevant dataset, not just the current page.

Query parameters

kindstringRequired
cities | compliance | quality | inspections
pageintegerOptional
One-based, from 1 to 1,000,000. A valid page beyond the last page is clamped to the last page.Default: 1
pageSizeintegerOptional
10 | 25 | 50 | 100Default: 25
sortstringOptional
cities only: name, slug, zoom or count. Other values use city ID.Default: id
dirstringOptional
cities only: desc reverses the sort; other values use ascending order.Default: asc
onlyLegalstringOptional
compliance only: the exact value false includes broader checks.Default: true
citystringOptional
quality only: all or a numeric city ID within the caller’s scope.Default: all
localestringOptional
quality only: sk localises issue details to Slovak; otherwise English.Default: en
issuestringOptional
quality only: an ID from summary.issues. Omit it to discover issue categories; items will be empty and total will be 0.
urgencystringOptional
inspections only: all, never, overdue, due_soon or ok. Other values match no records.Default: all
Analysis kindItems and summary
citiesEach city contains id, slug, name, center_lng, center_lat, default_zoom and crosswalkCount. Summary: total, crosswalks, withRecords.
complianceCrossing compliance records with open items. total counts crossings with gaps. Summary: total, withLegalGaps, conformant, openLegalItems, conformityPct and byStatute.
qualityEach item contains crosswalk and detail for the selected issue. Summary: cities, total, flagged and issues; each issue includes its count.
inspectionsEach item contains crosswalk, urgency, dueAt, daysUntilDue, daysSinceInspection, cadenceDays and rank. Dates are ISO strings or null. Summary: total, never, overdue, dueSoon, ok and currentPct; the urgency filter only narrows items and total.

Responses

HTTP statusMeaning
200Analysis page and summary, including empty datasets.
400Invalid pagination parameters, missing kind or unknown analysis kind.
401Missing session or a role without analyst access. Body: { "error": "Authentication required." }.
502Data could not be loaded; inspect error.

Request and response examples

Request · first page of citiesjavascript
const response = await fetch('/api/manage-analysis?kind=cities&page=1&pageSize=25&sort=name', {
  credentials: 'same-origin',
  cache: 'no-store',
})
const result = await response.json()
if (!response.ok) throw new Error(result.error ?? 'Request failed')
console.log(result.items, result.summary)
200 · illustrative city responsejson
{
  "items": [
    {
      "id": 1,
      "slug": "example-city",
      "name": "Example City",
      "center_lng": 17.11,
      "center_lat": 48.15,
      "default_zoom": 13,
      "crosswalkCount": 42
    }
  ],
  "total": 1,
  "summary": {
    "total": 1,
    "crosswalks": 42,
    "withRecords": 1
  }
}
Request variantshttp
GET /api/manage-analysis?kind=compliance&onlyLegal=true&page=1&pageSize=10
GET /api/manage-analysis?kind=quality&city=1&locale=en
GET /api/manage-analysis?kind=inspections&urgency=overdue&page=1&pageSize=25
400 · invalid paginationjson
{
  "error": "Invalid pagination parameters."
}

List contact messages

GET/api/manage/contacts
Rizicon admin onlyContactsPaginatedPrivate

Returns private contact submissions, most recent first, ordered by created_at then id descending. Each page has 20 records. Responses use Cache-Control: private, no-store.

Query parameters

statusstringOptional
all | new | in_progress | resolved | archivedDefault: all
pageintegerOptional
Zero-based. Invalid, negative or fractional values become 0; values above 10,000 are capped at 10,000.Default: 0

Responses

HTTP statusMeaning
200Returns items, total matching records, page and pageSize (20).
400Unsupported status filter; error is invalid_status.
401No signed-in session; error is unauthorized.
403Role is not rizicon_admin; error is forbidden. Mutations also reject an invalid origin with invalid_origin.
503Storage operation failed; error is unavailable.

Request and response examples

Request · new messagesjavascript
const response = await fetch('/api/manage/contacts?status=new&page=0', {
  credentials: 'same-origin',
  cache: 'no-store',
})
const result = await response.json()
if (!response.ok) throw new Error(result.error)
console.log(result.items, result.total)
200 · illustrative messagejson
{
  "items": [
    {
      "id": "00000000-0000-4000-8000-000000000001",
      "name": "Alex Example",
      "email": "alex@example.com",
      "organization": "Example City",
      "topic": "demo",
      "message": "We would like a demonstration for our city team.",
      "locale": "en",
      "status": "new",
      "admin_notes": "",
      "created_at": "2026-10-01T09:00:00+00:00",
      "updated_at": "2026-10-01T09:00:00+00:00",
      "privacy_accepted_at": "2026-10-01T09:00:00+00:00"
    }
  ],
  "total": 1,
  "page": 0,
  "pageSize": 20
}

Contacts start at page 0; management analysis starts at page 1. Contact listing does not accept a pageSize parameter.

Update a contact message

PATCH/api/manage/contacts
Rizicon admin onlyContactsWriteSame origin

Updates a message’s triage status and internal notes. Both status and admin_notes must be supplied, even if only one is changing. Notes are trimmed before storage. Cross-site requests are rejected.

JSON request body

idUUIDRequired
Message ID from the contact list.
statusstringRequired
new | in_progress | resolved | archived
admin_notesstringRequired
At most 5,000 characters before trimming. Send an empty string to clear notes.

Responses

HTTP statusMeaning
200Update saved; { "ok": true }.
400Invalid JSON, wrong content type or a body over 24,000 bytes: invalid_json. Invalid fields: validation.
401No signed-in session; error is unauthorized.
403Role is not rizicon_admin; error is forbidden. Mutations also reject an invalid origin with invalid_origin.
503Storage operation failed; error is unavailable.
404No accessible message matches the ID; error is not_found.

Request and response examples

Request · illustrative message IDjavascript
const response = await fetch('/api/manage/contacts', {
  method: 'PATCH',
  credentials: 'same-origin',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    id: '00000000-0000-4000-8000-000000000001',
    status: 'in_progress',
    admin_notes: 'Demo request assigned for follow-up.',
  }),
})
const result = await response.json()
if (!response.ok) throw new Error(result.error)
200 · savedjson
{
  "ok": true
}
404 · message not foundjson
{
  "error": "not_found"
}

Delete a contact message

DELETE/api/manage/contacts
Rizicon admin onlyContactsDestructiveSame origin

Permanently deletes one contact message. To retain the record, use PATCH with status archived instead. Cross-site requests are rejected.

Query parameters

idUUIDRequired
The message to permanently remove. No request body is used.

Responses

HTTP statusMeaning
200Message deleted; { "ok": true }.
400Missing or malformed ID; error is validation.
401No signed-in session; error is unauthorized.
403Role is not rizicon_admin; error is forbidden. Mutations also reject an invalid origin with invalid_origin.
503Storage operation failed; error is unavailable.
404No accessible message matches the ID; error is not_found.

Request and response examples

Request · illustrative message IDjavascript
const id = '00000000-0000-4000-8000-000000000001'
const response = await fetch('/api/manage/contacts?id=' + encodeURIComponent(id), {
  method: 'DELETE',
  credentials: 'same-origin',
})
const result = await response.json()
if (!response.ok) throw new Error(result.error)
200 · deletedjson
{
  "ok": true
}

Discover configured AI providers

GET/api/ai/providers
Analyst, admin or Rizicon adminAIConfiguration

Returns the providers with a non-empty server key configured: claude, openai and/or gemini. No key values are returned. An empty list means no server keys are configured; a user-supplied key can still be used with the POST routes. This checks configuration, not provider connectivity.

Responses

HTTP statusMeaning
200A providers array, which may be empty.
401Missing session or a role without analyst access. Body: { "error": "Authentication required." }.

Request and response examples

Request · configured providersjavascript
const response = await fetch('/api/ai/providers', { credentials: 'same-origin' })
const result = await response.json()
if (!response.ok) throw new Error(result.error)
console.log(result.providers)
200 · illustrative configurationjson
{
  "providers": [
    "claude",
    "openai"
  ]
}

AI requests may incur provider costs and send the supplied crossing or planning context to the selected provider. Examples use a configured server key. Never embed a real userKey in documentation or a public bundle.

Test an AI connection

POST/api/ai/test
Analyst, admin or Rizicon adminAIConnection test

Makes a small request to the selected provider. Read ok in the response body: missing keys and provider failures return HTTP 200 with ok: false and an error message.

JSON request body

providerstringRequired
claude | openai | gemini
userKeystringOptional
Optional provider key override. An empty or whitespace-only value falls back to the server key.

Responses

HTTP statusMeaning
200Connection result: { ok: true } or { ok: false, error }.
401Missing session or a role without analyst access. Body: { "error": "Authentication required." }.

Request and response examples

Request · test the server keyjavascript
const response = await fetch('/api/ai/test', {
  method: 'POST',
  credentials: 'same-origin',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ provider: 'openai' }),
})
const result = await response.json()
if (!response.ok || !result.ok) throw new Error(result.error ?? 'Connection failed')
200 · connectedjson
{
  "ok": true
}
200 · missing configurationjson
{
  "ok": false,
  "error": "No API key configured for this provider."
}

This beta route does not normalise malformed request bodies into a documented 400 response. Send the field types above and handle unexpected non-JSON or server errors.

Explain crossing suggestions

POST/api/ai/suggestions
Analyst, admin or Rizicon adminAISuggestionsJSON

Explains up to five of the supplied suggestions for a crossing. The response retains the supplied candidate fields and changes description only; it does not create measures or alter prices, priority scores or membership. With an empty existingSuggestions array and a configured key, the result is { suggestions: [] }.

JSON request body

providerstringRequired
claude | openai | gemini
userKeystringOptional
Optional provider key override. An empty or whitespace-only value falls back to the server key.
crosswalkCrosswalkRequired
A current crossing record, such as an item from GET /api/crosswalks. crosswalk_id must be an integer; retain the record’s full observed attributes.
existingSuggestionsSuggestion[]Required
At most 100 current app candidates. Each must have a string id, string title and array triggers. Preserve the full model, including description, category, costTier, costRange, baseEffectiveness, priorityScore and legalRefs.
localestringOptional
Use sk for Slovak; all other values select English.Default: en

Responses

HTTP statusMeaning
200suggestions array. Missing keys, provider failures and unusable AI replies also use 200 with suggestions: [] and error.
400Invalid JSON or validated input fields; { suggestions: [], error: "Invalid request." }.
401Missing session or a role without analyst access. Body: { "error": "Authentication required." }.

Request and response examples

Request · illustrative custom candidatejavascript
const dataResponse = await fetch('/api/crosswalks')
const data = await dataResponse.json()
if (!dataResponse.ok || data.error) throw new Error(data.error ?? 'Data unavailable')
const crosswalk = data.crosswalks[0]
if (!crosswalk) throw new Error('No crossing is available')

const candidate = {
  "custom": true,
  "id": "example-site-review",
  "title": "Review the crossing on site",
  "description": "Confirm the recorded conditions before commissioning work.",
  "category": "audit",
  "costTier": "low",
  "costRange": [
    200,
    400
  ],
  "baseEffectiveness": 0,
  "priorityScore": 60,
  "triggers": [
    "Local review requested"
  ],
  "legalRefs": []
}
const response = await fetch('/api/ai/suggestions', {
  method: 'POST',
  credentials: 'same-origin',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    provider: 'openai', crosswalk,
    existingSuggestions: [candidate], locale: 'en',
  }),
})
const result = await response.json()
if (!response.ok || result.error) throw new Error(result.error ?? 'Request failed')
console.log(result.suggestions)
200 · illustrative explanationjson
{
  "suggestions": [
    {
      "custom": true,
      "id": "example-site-review",
      "title": "Review the crossing on site",
      "description": "A site review can confirm the conditions behind this request. Check the latest crossing observations with the local team before commissioning work.",
      "category": "audit",
      "costTier": "low",
      "costRange": [
        200,
        400
      ],
      "baseEffectiveness": 0,
      "priorityScore": 60,
      "triggers": [
        "Local review requested"
      ],
      "legalRefs": []
    }
  ]
}
400 · rejected inputjson
{
  "suggestions": [],
  "error": "Invalid request."
}

The sample candidate and its EUR cost range are invented to show the complete input shape. In normal work, send the app’s actual candidates, including their saved cost evidence.

Review a budget plan with AI

POST/api/ai/budget-insights
Analyst, admin or Rizicon adminAIBudgetRead-only analysis

Returns a written review of the supplied plan and available crossing context. The result is text in content, potentially with Markdown formatting. It does not save or change the budget plan.

JSON request body

providerstringRequired
claude | openai | gemini
userKeystringOptional
Optional provider key override. An empty or whitespace-only value falls back to the server key.
planBudgetPlanRequired
Object with budget (EUR number) and items (BudgetPlanItem[]). Retain each item’s saved costEstimate when available.
plan.items[]BudgetPlanItemRequired
Each item: crosswalkId, suggestionId, title, category, estimatedCost, costRange [min, max], costTier, priorityScore, baseEffectiveness, crosswalkStreet, crosswalkCity and crosswalkSafetyClass (number or null). items may be empty.
crosswalksCrosswalk[]Optional
Current crossing records used as planning context. An omitted or null value becomes an empty array.Default: []
options.localestringOptional
sk for Slovak; otherwise English.Default: en
options.focusstringOptional
priorities | coverage | delivery. Unsupported values use priorities.Default: priorities

Responses

HTTP statusMeaning
200Returns { content }. Missing keys or generation failures return { content: "", error } with the same status.
401Missing session or a role without analyst access. Body: { "error": "Authentication required." }.

Request and response examples

Request · illustrative planjavascript
const dataResponse = await fetch('/api/crosswalks')
const data = await dataResponse.json()
if (!dataResponse.ok || data.error) throw new Error(data.error ?? 'Data unavailable')
const crosswalk = data.crosswalks[0]
if (!crosswalk) throw new Error('No crossing is available')

const plan = {
  budget: 5000,
  items: [{
    crosswalkId: crosswalk.crosswalk_id,
    suggestionId: 'example-site-review',
    title: 'Review the crossing on site',
    category: 'audit', estimatedCost: 300, costRange: [200, 400],
    costTier: 'low', priorityScore: 60, baseEffectiveness: 0,
    crosswalkStreet: crosswalk.street,
    crosswalkCity: crosswalk.city,
    crosswalkSafetyClass: crosswalk.last_safety_class,
  }],
}
const response = await fetch('/api/ai/budget-insights', {
  method: 'POST',
  credentials: 'same-origin',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    provider: 'openai', plan, crosswalks: [crosswalk],
    options: { locale: 'en', focus: 'delivery' },
  }),
})
const result = await response.json()
if (!response.ok || result.error) throw new Error(result.error ?? 'Request failed')
console.log(result.content)
200 · illustrative reviewjson
{
  "content": "## Delivery review\nThe selected review has an illustrative cost of €300, leaving €4,700 of the €5,000 budget. Confirm the scope and obtain a local quotation; the supplied €200–€400 range is an unverified estimate. No changes have been applied to the plan."
}
200 · missing configurationjson
{
  "content": "",
  "error": "No API key configured for this provider."
}

The sample amounts are illustrative. Supply a complete plan object: this beta route does not provide a uniform validation error for malformed input.

Start a streaming AI conversation

POST/api/ai/chat
Analyst, admin or Rizicon adminAINDJSONStreaming

Send the conversation history and crossing records that should be available as context. The most recent user message’s selectedCrosswalkId identifies the current selection. The server returns newline-delimited JSON frames with Cache-Control: no-store.

JSON request body

providerstringRequired
claude | openai | gemini
userKeystringOptional
Optional provider key override. An empty or whitespace-only value falls back to the server key.
messagesChatMessage[]Optional
Conversation in order. Include a user message to ask a question. Each message has role, content and timestamp; omission or null becomes an empty history.Default: []
messages[].rolestringRequired
user | assistant
messages[].contentstringRequired
Message text.
messages[].timestampnumberRequired
Unix time in milliseconds, as used by the app’s ChatMessage model.
messages[].selectedCrosswalkIdnumber | nullOptional
Selection when a user message was sent. Use the crossing ID, or null for dataset context; earlier selections stay attached to their original messages.
crosswalksCrosswalk[]Optional
Current records supplied to the AI. The route does not fetch crossing records automatically.Default: []
localestringOptional
Use sk for Slovak; all other values select English.Default: en

Responses

HTTP statusMeaning
200application/x-ndjson; charset=utf-8. Frames contain text deltas or a provider error.
400No API key configured. A normal JSON error body is returned before streaming starts.
401Missing session or a role without analyst access. Body: { "error": "Authentication required." }.

Request and response examples

Request body · conversation without crossing datajson
{
  "provider": "openai",
  "locale": "en",
  "messages": [
    {
      "role": "user",
      "content": "What crossing observations should our team collect before a review?",
      "timestamp": 1790845200000,
      "selectedCrosswalkId": null
    }
  ],
  "crosswalks": []
}
200 · illustrative successful streamndjson
{"t":"d","v":"Start with the recorded crossing attributes"}
{"t":"d","v":" and the date of the latest inspection."}
400 · JSON before the streamjson
{
  "error": "No API key configured for this provider."
}

The route expects the app’s message and crossing models and does not provide uniform malformed-input validation. Validate your payload before sending. A 200 status alone does not establish a successful AI answer.

Read and handle the chat stream

A network chunk can contain part of a JSON line, several lines, or part of a UTF-8 character. Decode incrementally and buffer until a newline before parsing. There is no separate completion frame: a successful response ends when the stream closes.

FrameClient behaviour
{ "t": "d", "v": "text" }Append v to the visible answer.
{ "t": "e", "v": "error message" }Treat the answer as failed, even after earlier text and HTTP 200. Show the error separately from model output.

Request and response examples

Complete browser example · read NDJSON safelyjavascript
async function askAI(question, onText, signal) {
  const response = await fetch('/api/ai/chat', {
    method: 'POST',
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    signal,
    body: JSON.stringify({
      provider: 'openai', locale: 'en', crosswalks: [],
      messages: [{
        role: 'user', content: question,
        timestamp: Date.now(), selectedCrosswalkId: null,
      }],
    }),
  })
  if (!response.ok) {
    const body = await response.json().catch(() => ({}))
    throw new Error(body.error ?? 'HTTP ' + response.status)
  }
  if (!response.headers.get('content-type')?.includes('application/x-ndjson')) {
    throw new Error('Expected an NDJSON response')
  }
  if (!response.body) throw new Error('No response stream')

  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let pending = ''
  let answer = ''
  const consume = (line) => {
    if (!line.trim()) return
    const frame = JSON.parse(line)
    if (frame.t === 'e') throw new Error(frame.v)
    if (frame.t !== 'd' || typeof frame.v !== 'string') {
      throw new Error('Unexpected stream frame')
    }
    answer += frame.v
    onText(answer)
  }

  try {
    while (true) {
      const { done, value } = await reader.read()
      pending += done ? decoder.decode() : decoder.decode(value, { stream: true })
      let newline
      while ((newline = pending.indexOf('\n')) !== -1) {
        consume(pending.slice(0, newline))
        pending = pending.slice(newline + 1)
      }
      if (done) break
    }
    if (pending.trim()) consume(pending)
    return answer
  } catch (error) {
    await reader.cancel().catch(() => {})
    throw error
  } finally {
    reader.releaseLock()
  }
}

const controller = new AbortController()
try {
  await askAI('What should a crossing review record?', console.log, controller.signal)
} catch (error) {
  console.error(error)
}
// Call controller.abort() to cancel.
200 · illustrative error after partial outputndjson
{"t":"d","v":"The available data suggests"}
{"t":"e","v":"The AI service is temporarily unavailable."}