Skip to content

Latest commit

 

History

History
406 lines (362 loc) · 73.7 KB

api.md

File metadata and controls

406 lines (362 loc) · 73.7 KB

HTTP API, webhooks and MCP

The HTTP API v1, the webhooks the server sends and the MCP endpoint for AI agents (19 read tools, 3 write tools).

API

Every request under /api carries Authorization: Bearer <token>; the Scope column says what it needs. Errors are RFC 9457 problem details. A 400 about a field of a body adds errors, an object from field to a list of messages; a search without a word, a bad chunk and a bad diagnostics body carry a title only. Objects always carry every field, null ones too. The API is additive, so apiVersion stays 1 and an app from an older version keeps working.

Method Path Scope Result
GET /healthz none 200 when the database answers
GET /api/v1/info read { serverVersion, apiVersion, scope, features, codecs }; scope is the caller's; codecs lists the chunk codec values the server accepts ([21], Opus 20 ms; every other id is refused with 400 naming the codec, 40 (LC3) and 50 (AAC) being the reserved ones; an older server omits the field and means [21]); features holds offline-sync, people, review, briefs, tags, tag-suggestions, roles (name suggestions carry roles and people have named), task-kinds (tasks have a kind and GET /api/v1/notes exists), waiting-on (tasks of kind waiting_on, and waitingOn on the person page), ledger (GET /api/v1/people/{id}/ledger exists), task-create (POST /api/v1/tasks, and tasks have a due and may have no conversation), speech-kind (speech kinds on lines), context-ranges (the server takes context ranges), auto-names (auto-applied names and their undo exist) and calendar-feed (GET /api/v1/calendar.ics exists, with or without a calendar feed set), review-clips (review items carry clip and context, and their clip route exists), transcript-upgrade (POST /api/v1/conversations/{id}/upgrade exists, and a conversation has transcript: { tier, route, pass, upgradable }; each segment has final and category), usage (GET /api/v1/usage and the usage and budget settings exist), semantic-search when an embeddings endpoint is set and its vectors are ready, so search and Ask also match by meaning (vector search), quality (GET /api/v1/quality exists), insights (GET /api/v1/insights exists, the Over time baselines and unlock progress), nudges (GET /api/v1/nudges exists), related (GET /api/v1/related exists and a conversation has related), labeling (the labelling routes exist and tokens may have the scope labeling), summary-templates (the summary template routes exist, and a conversation has summaryTemplate), voice when the speaker model is there, and voice-groups when voice grouping is on and works; briefs is listed whether or not a calendar feed is set, because the app reads calendar.icsUrl from GET /api/v1/settings
GET /api/v1/status admin { pendingChunks, oldestPendingAt, lastError, lastErrorAt, lastSuccessAt, ai: { configured, pending, lastError, lastErrorAt } }; a lastError is set only while it is current
GET /api/v1/summary-templates admin { enabled, maxLength, items: [{ id, text, builtIn, edited, locked }] } for default, meeting, lecture, doctor and call; enabled is the summary.templates.enabled setting, edited whether text differs from builtIn, locked that the environment sets it; see Summary templates
GET /api/v1/summary-templates/{id} admin One item as above; 404 for another id
PUT /api/v1/summary-templates/{id} admin Body { text }, 1 to 600 characters on one line, no line breaks or control characters (400 otherwise): sets the template and returns the item; the next summary uses it. 404 for another id; 409 when the environment sets it
DELETE /api/v1/summary-templates/{id} admin Restores the built-in text and returns the item; 404 and 409 as PUT
GET /api/v1/usage?from=&to= admin What transcription cost, by the price table stt.prices (Usage and budget): { timeZone, from, to, days, month, budget }. from and to are local dates (yyyy-MM-dd, both inclusive, at most 366 days, to at most today), by default the month so far. A day is { date, requests, audioMs, textChars, costUsd, estimated, overBudget, engines: [{ engine, requests, audioMs, costUsd }] }, every date listed; month is { month, requests, audioMs, costUsd, forecast: { costUsd, meanDayUsd, basisDays, daysLeft } }; budget is { monthlyUsd, warningPercent, model, state, remainingUsd, overBudgetBatches } with state none, ok, warning or over
GET /api/v1/quality?from=&to= admin The nightly quality metrics: { timeZone, from, to, days }. from and to are local dates (yyyy-MM-dd, both inclusive, at most 366 days, to at most today), by default the last 30 days; only measured days are listed, oldest first. A day is { date, measuredAt, conversations, lines, fragmentRate, untitledRate, emptyRate, wearerLineShare, wearerConversationShare, voiceScoredShare, unknownSpeakerShare, rawLabelShare, storedRawLabelShare, mediaShare, mediaLineShare, taggedShare, items: { vital, ordinary, noise, unrated }, unconfirmedMemories, voiceGroups, singletonShare, attribution, conversationStats, last7Days }; a rate is from 0 to 1 and null when its base is 0, voiceGroups and singletonShare are null on a backfilled day. attribution is { classShare, speechSeconds, labelsPerConversation: { mean, p50, p90 }, unmappedMinutes, goldenAccuracy, goldenLines, fragmentation, goldenPersons }, with classShare and speechSeconds each { wearer, person, label, media, none } (the attribution metrics; null on a day measured before they existed); boundary is { goldenF1, goldenBoundaries }; the golden fields of attribution and boundary score the system against the golden labels as it was when the row was measured, and are null on a backfilled day and while no golden label exists; conversationStats is { shortShare, perDay }; last7Days is { days, attributionDays, attribution, boundary, conversations } over the day and the six before it (the golden scores are those of its newest day that has them). The response also has derivations, the state of each derived layer now rather than per day (the nightly re-think): a list, each layer after the ones it follows, of { layer, enabled, dependsOn, version, staleDays, oldestStale, newestStale } for tracks, links, names, situations and enrichment; staleDays counts the local days with a row an older version made (0 while the layer is off), oldestStale and newestStale are the first and last of them or null
GET /api/v1/insights read The app's Over time tab, the "Over time" insights: { daysWithAudio, trustedAfterDays, insights }. daysWithAudio is how many local days have had a visible conversation, ever; trustedAfterDays is the insights.trustedAfterDays setting. Each of insights is { id, title, kind, unlocked, progress: { have, need, unit }, summary, series, baseline, unitLabel } for talk-time, people-week, open-commitments and time-of-day; progress is { daysWithAudio capped at trustedAfterDays, trustedAfterDays, "days" }. Locked (daysWithAudio < trustedAfterDays): no summary, series, baseline or unitLabel. Unlocked: summary is a one-line, numbers-only line (no model call), series the last 28 days as [{ date, value }], baseline the median of the last 28 days with audio, excluding today. Every computation leaves out the sensitivity classes Nytka__Privacy__Hide names, for every token
GET /api/v1/nudges?since=&tz= read The nudges the app shows as notifications: { items: [{ id, kind, title, body, deepLink, at }] }, oldest first, those shown from since (ISO 8601, default 24 hours ago) up to now. kind is commitment-due, recap, insight-unlocked or deviation; deepLink is a nytka:// link; at is when it may be shown. A repeat poll returns the same ids. Quiet hours and the day use tz (an IANA zone), else user.timeZone; 400 for a bad tz. Empty with nudges.enabled off
POST /api/v1/transcriptions/retry admin Transcribes every failed batch that still has its audio again, as late audio; 200 { queued }; see Failed batches
POST /api/v1/chunks admin Stores one chunk of Opus frames (application/vnd.nytka.frames.v1)
POST /api/v1/diagnostics admin Stores 1 to 500 diagnostics samples (JSON array, at most 256 KiB); answers { accepted }
GET /api/v1/diagnostics?since=&limit= admin Samples oldest first: { items, nextSince }; limit defaults to 500, caps at 5000
GET /api/v1/coverage?from=&to=&bucket=&limit= admin The "Nothing is lost" report: { from, to, timeZone, bucket, totals, buckets, gaps, gapsTotal, now, warnings }; see Running the no-loss wear test; bucket is day (default) or hour, to defaults to now, from to six days before today; limit defaults to 500, caps at 5000; 400 for a range over 62 days (14 for hours)
GET /api/v1/conversations?before=&since=&tag=&media=&noise=&limit= read { items, nextBefore }, newest first; an item is { id, startedAt, endedAt, status, preview, title, summary, aiStatus, bookmarks, source, mediaShare, transcriptTier, noise, sensitivity, tags, topics }, bookmarks being a count, source nytka or omi, mediaShare the share (0 to 1) of the conversation's speech time whose speech kind is media, tags sorted names, topics as on the detail; since keeps conversations that started at or after it; tag keeps those with that tag (400 for a name that is none); media is hide (leaves out those with a mediaShare of 0.8 or more) or only (keeps just those), else 400; noise (true for a closed conversation with fewer than 20 words outside media) filters the same way with hide or only; sensitivity is none, health, therapy, intimate, money or null, and a read token never sees the classes Nytka__Privacy__Hide names (sensitive content); limit defaults to 30, caps at 100
GET /api/v1/conversations/{id} read The item without preview, plus titleEdited, aiMessage, aiUpdatedAt, summaryTemplate (the summary template that wrote the summary, or null), tags, tasks, segments ({ id, startedAt, endedAt, text, speaker, voice, speakerId, isUser, personId, personName, isUserSource, wearerConfidence, speechKind, speechGuess, speechScore, speechSignals, speechMarked, marked }, marked true when a bookmark falls within 60 s of the line, speaker the provider's stored label and voice how readers see it (Voice n+1 for SPEAKER_n, as in summaries and MCP), isUserSource being manual, voice, provider or null and wearerConfidence 1 or 0 for your mark, the voice similarity for Nytka's verdict, else null, see Your voice; speechKind the speech kind that applies (person, media, call or null), speechGuess Nytka's guess (also unsure) with its speechScore and speechSignals (a list, empty when none), speechMarked true when your mark decides) bookmarks ({ id, at, note }) topics ([{ id, name, kind }], at most 3 topic pages it is on) and related, its Earlier links as GET /api/v1/related lists them
POST /api/v1/import/omi?overlapping= admin Body: an Omi export file; 200 with the counts of Import from Omi; 400 for a body that is no export; 413 above 100 MB
POST /api/v1/import/nytka admin Body: a file of GET /api/v1/export; restores it, keeping ids, skipping what is already here; 200 with the counts of Restore a Nytka export; 400 for a body that is no export, a newer format version, a cut file or a record the schema refuses (nothing written); 413 above 100 MB
POST /api/v1/import/transcript?format=&startedAt= admin Body: an SRT, VTT or TXT transcript; see Importing other sources; 200 { conversationId, segments }; 400 for a body that does not parse, or a startedAt with no UTC offset or outside the range a chunk's base time can hold; 413 above 100 MB
POST /api/v1/import/audio?startedAt= admin Body: a WAV or Ogg Opus file; see Importing other sources; 202 { sessionId, durationMs }; 415 for an unsupported content type, 400 for bad audio or a startedAt with no UTC offset or outside the range a chunk's base time can hold, 413 above 100 MB
PATCH /api/v1/conversations/{id} admin Body { title }, 1 to 120 characters, or null for the generated title
POST /api/v1/conversations/{id}/upgrade admin Queues a paid pass over the draft lines: 202 { status: "queued" } ("upgrading" when one is running), 200 { status: "final" } when nothing is left to pay for, 409 { reason } when no Soniox key is set, the month's budget is spent, the speech audio is gone or a batch is still being transcribed; ?reason=manual marks a pass you asked for (default opened)
POST /api/v1/conversations/{id}/enrich admin Queues a summary run: 202 { aiStatus: "pending" }; 409 while the conversation is open or no model is set
POST /api/v1/speech/backfill?force= admin Queues classify-speech for closed conversations with unguessed lines (force=true: also those guessed by an older speech_version). 200 { queued, remaining }: at most 500 are queued per call, remaining counts the rest; nothing is queued while Nytka__Speech__Mode is off; see Speech kind
POST /api/v1/conversations/{id}/speech admin Body { kind }: person, media, call, or null to clear. Marks every line of the conversation that is not yours, in every mode: 200 { marked }, the number of lines; 400 for another kind, 404 for an unknown conversation; see Speech kind
DELETE /api/v1/conversations/{id} admin Deletes it with its transcript, audio, tasks, memories and labels
GET /api/v1/conversations/{id}/transcriptions admin Raw transcription responses
GET /api/v1/conversations/{id}/audio read The conversation's speech as audio/ogg (Opus, packed without re-encoding); pauses are not stored, so they are not played; range requests work; 404 when no speech audio is stored
GET /api/v1/conversations/{id}/audio/index read { durationMs, runs: [{ offsetMs, startedAt, endedAt }] }: each stretch of continuous capture and where it starts in the stream; 404 as above
GET /api/v1/tasks?status=&conversationId=&before=&kind=&limit= read { items, nextBefore }, newest first; a task is { id, conversationId, conversationTitle, conversationStartedAt, text, done, doneAt, createdAt, personId, personName, kind, due, salience, markedAt, markedSegmentId } (salience vital, ordinary, noise or null, vital whenever markedAt is set, the time of a bookmark within 60 s of a line the task cites, markedSegmentId that line; the conversation fields are null for a task made by hand); status is open (default) or done; kind is commitment (default), idea, waiting_on or all, else 400; limit defaults to 50, caps at 200
GET /api/v1/notes?topic=&conversationId=&before=&limit= read { items, nextBefore }, newest first; a note is { id, conversationId, conversationTitle, conversationStartedAt, topic, points, createdAt, salience, salienceManual }; topic is a name (400 for one that is none); limit defaults to 50, caps at 200
POST /api/v1/tasks admin body { text, due?, conversationId?, personId? }, text 1 to 200 characters, due ISO 8601 or null (400 otherwise); answers 201 with the task, kind commitment, marked edited; 404 for an unknown conversation or person, 409 when the conversation already has a task with this text, or, without a conversation, an open task made by hand has it
PATCH, DELETE /api/v1/tasks/{id} admin PATCH body { text?, done?, personId?, due?, salience? }, text 1 to 200 characters, salience your mark (vital, ordinary, noise, or null to take it back; 400 for another value), personId a person id or null (404 for an unknown person), due ISO 8601 or null to clear it; 409 when reopening a task made by hand that an open one repeats; DELETE answers 204
GET, PATCH /api/v1/settings admin GET: { items: [{ key, type, value, isSet, source, locked, default }] }. PATCH body { values: { "<key>": value or null } }, all or nothing, null restores the default; 400 for an unknown key or a bad value, 409 for a locked key or any API key
POST /api/v1/tokens admin Body { name, scope, sensitive? }; sensitive true trusts the token with sensitive content, only when it is made; 201 with the token's fields (sensitive among them) and token, shown once; 409 for a name in use
GET /api/v1/tokens admin { items }, newest first, revoked ones included: { id, name, scope, hint, createdAt, lastUsedAt, revokedAt }
DELETE /api/v1/tokens/{id} admin Revokes it: 204
POST /api/v1/pair admin Body { name, url }, url an absolute http or https URL of at most 500 characters; 201 with { id, name, createdAt, token, payload, qrCodePng }: a fresh admin token, shown once, and the same pairing QR code as text (payload) and as a PNG (qrCodePng, base64); 409 for a name in use
GET /api/v1/memories?before=&limit=&expired= read { items, nextBefore }, newest first; a memory is { id, text, source, conversationId, conversationTitle, conversationStartedAt, createdAt, updatedAt, salience, expiresAt, markedAt, markedSegmentId }, source is ai, user or omi, salience vital, ordinary, noise or null (vital whenever markedAt, a bookmark within 60 s of a cited line, is set; markedSegmentId is that line), expiresAt when the fact stops being true or null; memories past their expiresAt are left out unless expired=true; limit defaults to 50, caps at 200
POST /api/v1/memories admin Body { text }, 1 to 300 characters; 201 with the memory; 409 when a live memory holds the fact
POST /api/v1/memories/classify admin Queues classify-memories, which gives every live memory without a sensitivity one from its text alone, 100 per model call and repeating until none is left (a memory the model leaves out stays unclassified); 202 { unclassified }, the count waiting; 409 without a model
GET /api/v1/ai/backfill/conversations?after=&limit= admin Closed conversations that are not noise, by id: { items: [{ id, startedAt, memories }], nextAfter }, memories the memories a re-check would judge; limit defaults to 50, caps at 500 (backfill with Claude Code)
POST /api/v1/ai/backfill/{kind}/{id} admin kind recheck, enrich or memories; body { answers: { key: json } }. Runs the job with the answers given and calls no model: 200 { status, key, schemaName, schema, system, user, reason }, status pending (answer this request under key), done, busy or invalid (reason a fixed sentence such as the answer not matching the schema, null otherwise); 400 for another kind, 404 for no conversation
PATCH, DELETE /api/v1/memories/{id} admin PATCH body { text?, salience? }, at least one: salience is your mark as for a task; DELETE answers 204
PATCH /api/v1/notes/{id} admin Body { salience }, your mark (vital, ordinary, noise, or null to take it back); 200 with the note, 404 for an unknown one
GET /api/v1/bookmarks?before=&limit= read { items, nextBefore }, newest first; a bookmark is { id, at, note, source, conversationId }, source is pendant or app; before is a time and beforeId the id of the last item of the previous page (nextBefore, nextBeforeId), so equal times are not skipped; limit defaults to 30, caps at 100
POST /api/v1/bookmarks admin Body { id, at, note?, source }, id a UUID the client makes, at with an explicit offset, note up to 200 characters; 201 with the bookmark, or 200 with the stored one when the id exists
PATCH, DELETE /api/v1/bookmarks/{id} admin PATCH body { note }, null clears it; DELETE answers 204
POST /api/v1/context/ranges admin Body { items: [{ id, kind, route, startedAt, endedAt, detail?, contact?, contactHash? }] }, 1 to 500 items: id a UUID the app makes; kind with the routes and detail it takes: media and call (any route, no detail; a call may carry contact, the display name the phone showed, 1 to 80 characters, and contactHash, the SHA-256 of the number as 64 hex characters, only with a contact; never the number), screen (other), headphones (headset or bluetooth), activity (other; still, walking, running, cycling or vehicle), now-playing (speaker, headset, bluetooth or other; video, music, podcast, messenger, browser or other), gain (other; the pendant's gain step "0" to "8"); times with an explicit offset, endedAt not before startedAt and at most 12 hours after it, startedAt from 1970 on and at most 24 hours ahead of the server's clock. 200 { accepted, skipped, rejected }: skipped counts ranges whose id is already stored, a repeat inside the batch included; rejected lists { id, reason } for each item refused on its own (unknown-kind, bad-route, bad-detail, bad-contact for a contact or hash that is wrong or sits on a kind but call, bad-times), which counts in neither. An item that is no object or has no UUID id makes the upload a 400 that names it (items[2].id), never a value, and stores none of the batch; see Context from the phone
GET /api/v1/context/ranges?since=&until=&limit= admin { items }, oldest start first (pass since for the recent ones): { id, kind, route, startedAt, endedAt, detail, contact, contactHash }; since keeps ranges that started at or after it and until those that started before it; limit defaults to 200, caps at 1000
GET /api/v1/digests?before=&limit= read { items, nextBefore }, newest date first; a digest is { id, localDate, headline, overview, highlights: [{ text, conversationId }], decisions, openQuestions, createdAt }; before is a date (yyyy-MM-dd) and keeps earlier ones, nextBefore is the last date of the page, set only when an earlier digest exists; limit defaults to 30, caps at 100
GET /api/v1/digests/{id} read One digest, as in the list
POST /api/v1/digests/run?date= admin Queues a run for that local date that replaces its digest; 202 { localDate }, 400 for a missing, malformed or future date, 409 without a model
GET /api/v1/export admin Streams everything you own as NDJSON (application/x-ndjson); see Export
GET /api/v1/export/obsidian admin A ZIP (application/zip) of Markdown notes for an Obsidian vault, one per conversation and one per person; see Export
GET /api/v1/search?q=&kinds=&tag=&limit=&offset= read { items, nextOffset }; a hit is { kind, id, score, title, snippet, at, conversationId }; kinds is conversation, memory and person (a person's id is the person's, title the name); with vector search on, hits close in meaning join the word hits and score is the fused rank
POST /api/v1/webhooks admin Body { url, events, format?, description?, sensitive? }, sensitive true (only when it is made) lets it receive sensitive content, format is nytka (default) or omi, description up to 200 characters; 201 with the webhook and secret, shown once; 409 at 20 webhooks
GET /api/v1/webhooks admin { items }: { id, url, events, format, description, active, createdAt, lastDelivery }, lastDelivery is { status, at } or null
PATCH, DELETE /api/v1/webhooks/{id} admin PATCH body with any of url, events, format, description, active; DELETE answers 204 and drops its deliveries
POST /api/v1/webhooks/{id}/test admin Queues a ping: 202 { deliveryId }
GET /api/v1/webhooks/{id}/deliveries?limit= admin { items }, newest first: { id, eventType, status, attempts, lastStatusCode, lastError, createdAt, deliveredAt }; limit defaults to 30, caps at 100
GET /api/v1/voice admin { enrolled, enrolledAt, updatedAt, enrolledSamples, learnedSegments, voiceprints, modelAvailable }; never a voiceprint vector. voiceprints is how many centroids the wearer's voice is kept as (NYTKA-159)
POST /api/v1/voice/enrollment?mode= admin Body: one or more chunks of Opus frames back to back (application/vnd.nytka.frames.v1) or a 16 kHz mono 16-bit WAV (audio/wav), at most 120 s; mode is replace (default) or add. 200 { speechSeconds, samples, minAgreement }; 422 with reason too-little-speech, too-few-samples or samples-disagree and the same three numbers; 413 over 120 s; 415 another content type; 400 unreadable audio; 409 add to a voiceprint of another model; 503 without the speaker model
POST /api/v1/voice/reset admin Back to the enrolled voiceprint, forgetting what it learned; 200 as GET, 404 with no voice enrolled
DELETE /api/v1/voice admin Forgets your voice: voiceprint, fingerprints, similarities and verdicts, and every voice group; your marks stay. 204, also with nothing enrolled
GET /api/v1/voice/segments?since=&until=&limit= admin { items, nextSince }, oldest first, for choosing a threshold: { segmentId, conversationId, startedAt, endedAt, similarity, voiceIsUser, providerIsUser, manualIsUser }, no text; since keeps segments that started after it; limit defaults to 500, caps at 5000
PATCH /api/v1/segments/{id} admin Body { isUser?, personId?, speechKind? }, at least one: isUser is true ("this is me"), false or null (clears the mark); a change of isUser judges again, without a model, the memories that cite the line (marks); personId is a person, or null to clear the segment's own person, see Speaker labels; speechKind is person, media, call or null (clears your mark) on any line, yours included, see Speech kind; 200 with the segment as a conversation shows it; 404 for an unknown segment or person
GET /api/v1/people?tag= read { items } by name: { id, name, note, createdAt, voices, segments, lastSeenAt, factCount, tags, named }; tag keeps people with that tag (400 for a name that is none); lastSeenAt is the newest segment of the person, as on the person page, null when never heard; factCount counts their live facts
PATCH /api/v1/people/{id} admin Body { name?, note? }, at least one: name 1 to 80 characters (and sets named to true), note up to 500, null clears it; 200 with the person, 409 for a name another person has; a rename also respells the old name in the generated titles and summaries of the person's conversations and in their open tasks you did not edit (whole name, any case; no case endings), never your own titles or transcripts
GET /api/v1/people/{id} read The person page: { id, name, note, createdAt, lastSeenAt, voices, hasVoiceprint, voiceprintSamples, conversations: [{ id, title, startedAt }], facts: [Fact], openTasks: [Task], tags, named, waitingOn: [Task] }; 404 for an unknown person
GET /api/v1/people/{id}/ledger read The promise ledger: { personId, personName, owedByMe: { open: [Task], done: [Task] }, owedToMe: { open, done } }, newest 100 per list; 404 for an unknown person
POST /api/v1/people/{id}/forget admin Forgets the person completely, in one transaction: 200 with { counts: { lines, facts, memories, tasks, notes, voiceprints, voiceMatches, voiceGroups, nameSuggestions, tags, tagSuggestions, topicPages, events, briefs, conversations }, voiceprintsForgotten }; 404 for an unknown person, 403 without an admin token
GET /api/v1/people/suggestions?status=&limit= read { items }, newest decided (else made) first; limit 1 to 200, default 200; status is pending (default), accepted, rejected or auto_applied (auto-applied names); an item is { id, conversationId, target, speakerId, groupId, name, role, named, personId, confidence, sameName, evidence: { segmentId, startedAt, text }, decidedAt, clip } (clip as a review item's, the evidence line's audio, null once it is gone; decidedAt: when it was answered or applied, null while pending; sameName: pending suggestions with the same name, any case, itself included), target being speaker, label, group (groupId is the voice group) or person (the voice of a person known only by a role; personId is that person); role is a tag name or null, and named false means the role alone, name being its display form; see People
POST /api/v1/people/suggestions/{id}/accept admin Names the voice, the batch's segments or the voice group (as its card is named), creates the person known by a role, or renames or merges the person of a person target; 200 with the person; 404 for an unknown suggestion; 409 when it is no longer pending
POST /api/v1/people/suggestions/accept-by-name { name } admin Accepts every pending suggestion of that name (any case) for a voice, label or voice group as accept does, in one transaction, so the name is one person; role-only and person suggestions stay; 200 with { person, accepted, skipped } (skipped: no longer applies, as a voice that belongs to someone else); 404 when none is pending; 400 for a missing or blank name; 409 when the pending ones disagree; see People
POST /api/v1/people/suggestions/revalidate admin Deletes pending name suggestions the model made that fail the name checks; 200 with { checked, removed, kept }
POST /api/v1/people/suggestions/{id}/reject admin 204; the name is never suggested again for that voice; 404 and 409 as accept
POST /api/v1/people/suggestions/{id}/undo admin Takes back an auto-applied name and makes the suggestion rejected; 204; 404 for an unknown suggestion; 409 when it is not auto_applied
POST /api/v1/people/backfill?limit=&force= admin Queues name suggestions and facts for summarized conversations they have not read (force=true: also names runs made under older checks, and conversations that still hold pending name suggestions, which the run re-decides); limit 1 to 1000 (default 200), 400 outside it; 200 with { queued: { suggestNames, facts }, skipped, remaining }; 409 without a language model
GET /api/v1/people/voice-eval?since=&until=&limit= admin { items, nextSince }, oldest first, for voice grouping: { segmentId, conversationId, durationMs, groupId, personId, matchPersonId, similarity }, no text and no vector; limit defaults to 500, caps at 5000
GET /api/v1/speech/eval?since=&until=&limit= admin { items, nextSince }, oldest first, for speech kind: { segmentId, conversationId, startedAt, durationMs, isUser, guess, score, signals, marked, kind }, no text; limit defaults to 500, caps at 2000
GET /api/v1/people/cards admin { items }, at most 4 and at most 2 per conversation, newest first, empty while voice matching is off: { kind, id, conversationId, conversationTitle, personId, personName, similarity, clip: { from, until }, lines: [{ segmentId, startedAt, text }] }; kind is group ("Who is this?", no person or similarity) or match ("Is this Olena?"); see Voice grouping
GET /api/v1/people/cards/{kind}/{id}/clip admin The card's clip, audio/ogg, at most 10 s; 404 for an unknown card or when its audio is gone
POST /api/v1/people/cards/{kind}/{id} admin Body { personId }, { name }, { skip: true } or { reject: true }, exactly one; naming or confirming answers 200 with the person, skipping (7 days) or rejecting 204; 404 for an unknown card or person; 400 for a match named with someone else
GET /api/v1/tracks?day= admin { tracks } of the local day yyyy-MM-dd (all days without it), newest day first and in order within a day: { id, day, ordinal, seconds, segments, personId, personName, dismissed, suggestions: [{ id, kind, personId, personName, otherTrackId, otherDay, otherOrdinal, similarity, name, evidence }] }; kind is person (like that person's voiceprint), track (like a track of an earlier day), call (the contact of a phone call, similarity is its confidence, 0.9) or meeting (an attendee of a calendar event, confidence 0.6). A call or meeting suggestion carries the name it suggests (personId is the person with that name, null for a new one) and its evidence: { range, startedAt, endedAt } of the call range, or { uid, startsAt, endsAt } of the event. No vector and no text. See Speaker tracks and Names from context
POST /api/v1/tracks/build admin Body { day } (yyyy-MM-dd, today included); queues build-tracks for the day (with Nytka__Tracks__Meaning on, a build also queues link-speakers for the day's scenes not asked yet, and the build after the last one follows their answers), 202 { day }; 400 without a valid day, 409 while Nytka__Tracks__Enabled is off
POST /api/v1/situations/build admin Body { day } (yyyy-MM-dd, today included); queues build-situations for the day (the day is cut from the start: the links within its situations are asked for those with no run yet, and with Nytka__Tracks__Meaning on the tracks are built again and the day is cut once more), 202 { day }; 400 without a valid day, 409 while Nytka__Situations__Enabled is off
POST /api/v1/tracks/answers admin Body { segmentId, otherSegmentId, answer }, answer same or different: the next build of the day joins or splits the two lines' voices, and keeps doing so; a new answer about the same two lines replaces the old one. 204; 400 for a missing field, equal ids or another answer; 404 for a line that does not exist
POST /api/v1/tracks/suggestions/{id} admin Body { decision }, accept or reject, of a pending suggestion. Accepting a person sets it on the track, which names every line in it; accepting a track gives this track the other track's person; accepting a call or meeting gives it the person with the suggested name, a new person when there is none. 204; 409 when that track has no person; 404 for a suggestion that is not pending; 400 for another decision
GET /api/v1/review?limit= read { items }, newest first, limit 1 to 200 (default 50): { kind, id, conversationId, conversationTitle, at, text, proposal: { name, personId, confidence, similarity, isUser, tag, role, named, speechKind, lines, task }, clip, context }; kind is name, voice (none while voice matching is off), label, tag, speech or task (a possible duplicate task, task being the open one: { id, text, conversationId, conversationTitle }); clip is { url, startMs, endMs } (capture times in Unix milliseconds) or null when the item has no speech or its audio is gone, context up to 3 lines either side of the evidence, oldest first: [{ segmentId, speaker, personName, isUser, text, startedAt, evidence }], empty for a tag; see Review
GET /api/v1/review/{kind}/{id}/clip read The item's evidence as audio/ogg, 3 s either side, at most 20 s, cut by capture time; a name item's clip works whatever the suggestion's status; 404 for an unknown kind or item, a tag, or audio retention has deleted
POST /api/v1/review/{kind}/{id}/accept admin name and voice: 200 with the person; label: stores Nytka's verdict as your mark, 204; tag: 200 { tags }, the conversation's; task: deletes the new task and links its conversation to the open one, 200 with the kept task; 404 for an unknown kind or item; 409 for a name, tag or task pair that is no longer pending, or a tag when the conversation has 20
POST /api/v1/review/{kind}/{id}/reject admin 204; a label stores the opposite of Nytka's verdict as your mark, a task keeps both tasks; 404 and 409 as accept
DELETE /api/v1/people/voiceprints admin Deletes every voice group, every person voiceprint and every pending voice match; segment links stay. 204
GET /api/v1/people/{id}/facts?before=&limit= read { items, nextBefore }, newest first; a fact is { id, personId, text, source, basis, conversationId, conversationTitle, segmentId, createdAt, updatedAt }, source is ai or user, basis is said, about, mentioned or null; limit defaults to 50, caps at 200; 404 for an unknown person
POST /api/v1/people/{id}/facts admin Body { text }, 1 to 300 characters; 201 with the fact (source: user); 409 when a live fact of the person holds it; 404 for an unknown person
PATCH, DELETE /api/v1/people/{id}/facts/{factId} admin PATCH body { text }, 200 with the fact; DELETE answers 204; 404 for an unknown fact
GET /api/v1/tags?q= read { items: [{ name, conversations, people, uses }] }, most used first, then by name; q keeps names that start with it; see Tags
GET /api/v1/topics?kind=&q=&limit= read { items }, the topic pages with at least one conversation, most recently on first; a topic is { id, kind, name, tagId, personId, summary, moments, openCommitments, lastAt, updatedAt }, kind tag or person (else 400), summary null until the first fold, moments its conversations; q keeps names that start with it; limit defaults to 50, caps at 200
GET /api/v1/topics/{id}?before=&limit= read The topic plus timeline ([{ conversationId, startedAt, at, title, line, segmentId }], newest first; line the summary's first sentence; for a person at and segmentId are their first line, for a tag the start and null), nextBefore (pass as before; null on the last page) and commitments (its open commitments as tasks, said in its conversations or, for a person, owed to them; at most 50); limit defaults to 30, caps at 100; 404 for no such topic
GET /api/v1/today?tz= read { date, items }: the items worth a look on the owner's local day (tz is an IANA zone, else user.timeZone; 400 for an unknown one), at most 7, in the order they sit in the day. Open commitments and what is owed to you carry over from earlier days until done. An item is { kind, id, text, salience, salienceManual, sensitivity, at, conversationId, personId, personName, due, done, evidence, part }: kind is task, memory or note, at when it was said (its first cited line, else its conversation's start), part is morning, day (12:00 to 18:00) or evening (an open loop said earlier is morning), evidence the cited lines, at most 3 in time order, each { segmentId, conversationId, at, speaker, isUser, personId, quote, wearerConfidence } (speaker as a transcript shows it, Unknown for a line nobody labelled; quote cut to 280 characters). What is shown: the Today rule
GET /api/v1/timeline?date=&tz= read { date, scenes }, the day (default today) as one scene per conversation that started in it, oldest first: { id, conversationId, startedAt, endedAt, title, summary, people: [{ personId, name }], place, noise, sensitivity, items }; people leaves the wearer out and names a voice nobody named "Voice n"; place is null until the server knows places; noise marks a fragment too short to summarize; items is every live item of the conversation in any salience, shaped as on Today without part; 400 for an unknown tz or a date that is none. Classes the reader may not see (Sensitive content) are left out, scenes and items. With timeline.scenes on, a conversation is cut into scenes: each has the same fields plus startsWith (conversation_started, person_joined, person_left, media_started, call_started or topic_shift), the first keeps the conversation id as id and the next take {conversationId}:n, and items holds what was said in its span. With Nytka__Situations__Enabled on, the day is returned as situations instead: the same fields as a timeline.scenes scene plus version, the number of times the stored situation's content changed (1 for a new one; 0 for a conversation whose day is not built yet, which is shown as the scenes it would be cut into); id is the situation's own, title and summary are its own once enrich-situation has run, else the conversation's, people are the people in its lines, and startsWith also takes person_joined and person_left for a speaker track that came or went
GET /api/v1/rollups?period=&topicId=&before=&limit= read { items, nextBefore }, summaries over time newest first; a rollup is { id, period, start, end, topicId, locked, progress: { have, need, unit }, summary, changes, sources: { digests, conversations }, generatedAt }, period week, month, year or all (else 400), start and end local dates (end exclusive), changes what differs from the period before; locked true while the period had too little (progress, unit days, or conversations for a topic) and then summary and changes are null; topicId lists that topic's weeks instead of everything's; before a date; limit defaults to 12, caps at 100
GET /api/v1/rollups/{period}/{start} read One rollup of everything, as in the list; start is its first date (any date for all); 404 when there is none
POST /api/v1/rollups/backfill admin Body { from? } (a date; default the first with data): queues the rollup of every closed week, month and year since, every topic's closed weeks and all time, at most one model call each; 202 { queued }; 409 while rollups.enabled is off or no model is set
GET /api/v1/events?date=&tz= read { date, events }, a day's derived events (default today), oldest first: { id, kind, at, conversationId, personId, voiceLabel, detail, source }; kind is person_joined, person_left, media_started, media_stopped, call_started, call_ended, silence or alone; tz is an IANA zone, else user.timeZone (400 for an unknown one or a date that is none). An event tied to a conversation whose sensitivity class the reader may not see is left out
GET /api/v1/related?conversationId=|taskId=|memoryId=|noteId=&limit= read { items }, the Earlier links of exactly one conversation, task, memory or note (else 400), best first; an item is { kind, conversationId, title, at, segmentId, quote, score, reasons, people, tags }: kind is conversation, conversationId the earlier conversation, at and segmentId its line nearest the asked one (quote that line, at most 280 characters), score 0 to 1, reasons meaning plus person and tag when people or tags are shared; a task's, memory's or note's links are its conversation's, at the line nearest the item; empty for a deleted or unconfirmed memory, and for items until the vectors are ready; limit defaults to 5, caps at 20
PUT, DELETE /api/v1/conversations/{id}/tags/{name} admin Adds or removes one tag: 200 { tags }, the conversation's tags sorted; removing a tag it does not have is also 200; 400 for a name that is none, 404 for an unknown conversation, 409 when adding a 21st tag
PUT, DELETE /api/v1/people/{id}/tags/{name} admin The same for a person
POST /api/v1/tags/{name}/rename admin Body { name }; 200 with the tag; 404 for an unknown tag; 409 when the new name is another tag's (merge instead)
POST /api/v1/tags/{name}/merge admin Body { into }; moves every link to into (created when new), drops duplicates and the old tag; 200 with into; 404 for an unknown tag
GET /api/v1/tags/suggestions?status= read { items: [{ id, conversationId, personId, personName, name, createdAt }] }, newest first, at most 200; status is pending (default), accepted or rejected, else 400; personId is null for a conversation's tag; see Tags
POST /api/v1/tags/suggestions/{id}/accept admin Adds the proposed tag to its conversation, or to its person when personId is set: 200 { tags }, the item's; 404 for an unknown proposal; 409 when it is no longer pending or the item has 20 tags
POST /api/v1/tags/suggestions/{id}/reject admin Keeps the proposal as rejected, so the tag is not proposed again for that conversation or person: 204; 404 and 409 as accept
DELETE /api/v1/tags/{name} admin Removes the tag from every conversation and person: 204; 404 for an unknown tag
GET /api/v1/briefs/upcoming?minutes= read { items }, soonest first: the calendar events not over yet that start within minutes (1 to 1440, default 60, clamped): { uid, title, startsAt, endsAt, attendees: [{ name, personId }], brief: { id, text, createdAt } or null }; personId is the person whose name equals the attendee's, else null
GET /api/v1/calendar.ics read An RFC 5545 calendar (text/calendar), the calendar feed's events not over yet as VEVENT and your open tasks with a due date as VTODO; ?tasks=event writes those tasks as VEVENTs titled Commitment: … instead, ?tasks=both as both (default todo, anything else 400); also takes a read token as ?token= (see Calendar feed for the risk)
POST /api/v1/ask read Body { question }, 1 to 500 characters; { answer, sources } (see Ask); 503 without a model, 504 on a model timeout, 502 on any other model failure
POST /mcp read MCP

An unknown id is a 404, a missing or wrong token a 401, a token with too little scope a 403. aiStatus is none, pending, done, skipped or failed. The chunk format and the upload answers are specified in docs/specs/v0.1.md, the other objects in v0.2 and v0.4.

Diagnostics are health samples from the app (link quality, queue and upload state) that the app sends only while its developer switch is on: no audio, no transcripts, no token. Samples older than 30 days are deleted daily; there is nothing to configure.

Labelling

The routes the labeller apps (nytka-app/labeler, nytka-app/labeler-android) call; see Labelling. All are under /api/v1/labeling and take a token of scope admin or labeling, in the header only. A labeling token reaches nothing else. A 400 here is { field, error }, naming the bad field (items[2].source in an import), not a problem details object; a 404 is one.

Axis values are fixed codes of taxonomy version 1; new ones are appended and none is renamed. source holds 1 or 2 of me, not_me, live, device, call, none, cant_tell (imports may also use in_room and not_in_room); deviceType is tv, phone, laptop, car, assistant or other; content is speech, singing, vocal, music, other_sound or silence (imports: not_speech); transcript is right, small_errors, wrong_words, made_up or cant_hear (imports: partly); flags holds any of quiet, noisy, overlap, muffled, clipped, two_speakers; language is uk, ru, en, mixed or other; overlap is one, two, three_plus or cant_tell. The tasks and the axis each asks (ask): device and voice ask source, quiet and transcript ask transcript, overlap asks overlap.

Two more tasks, speaker and boundary, build the golden set (Labelling). They exist only for the days in Nytka__Eval__GoldenDays: with none set, /tasks does not list them and /next returns no items for them. Their ask is the task's own name, they are never part of mixed, and an answer to one is a golden label, not a label: it is stored in golden_labels, labelId is null, and nothing on the line changes. speaker asks who spoke one line; its answer is answer with wearer, person (and personId), same-as (and sameAsSegmentId, another line of any golden day; 400 for one that is not), new-voice, media, crowd or unsure. boundary asks whether two adjacent lines belong to one situation; its answer is answer with same-situation or new-situation. A missing or unknown answer is 400 with field: answer.

Method Path Result
GET /tasks { tasks: [{ task, ask, open, labelled, goal, goalLabel, progress }] }: items waiting, current labels, and for device the detector's ship gate (goal 100 not-device lines, progress the labels that count); goal, goalLabel and progress are null for the others. While golden days are set, speaker and boundary follow, counted over those days (labelled is their golden labels)
GET /next?task=&limit= Leases up to limit (1 to 50, default 30) items for 30 minutes: { sessionId, leasedUntil, items }. task is a task or mixed (every task, in runs of 3 to 5 of one task); else 400. While a lease of the same task is open, the same session's items not yet answered. A session holds at most 3 items of one conversation and no voice (speakerKey) more than 4 times in a row. An item is { id, task, ask, reason, conversationId, startedAt, durationMs, lines, contextBefore, contextAfter, guess, prefill, peaks, speakerKey }: reason uncertain, random, disagree, repeat or top; lines and the context lines (up to 2 either side, the nearest that overlap the 4 s around it) are { segmentId, text, isUser, startMs, endMs } in milliseconds from the item's start, so context before is negative; guess is { model, value, score } or null; prefill is { language } from the line's script; peaks 100 numbers from 0 to 1 (the loudest slice is 1), null when the audio was gone; speakerKey an opaque key of the voice, or null. A speaker or boundary session is not mixed: the open items of the golden days, the longest speech first (reason is golden, speakerKey is set for speaker), up to limit. A speaker item has one line; a boundary item has the two adjacent lines, the earlier first, conversationId that of the earlier line, and durationMs spanning both and the gap between them
GET /{id}/clip?context= audio/ogg (Opus) of the item and context seconds either side (0 to 4), at most 20 s, cut by capture time (a boundary item plays the last 10 s of its first line and the first 10 s of its second, whatever lies between); range requests work; Cache-Control: no-store. Once the audio is gone, the clip kept with its label (Nytka__Labeling__KeepClips) without context, else 404
POST /{id} Body { clientId, source, deviceType, content, transcript, flags, language, personId, correction, overlap, linesDiffer, skipped, listenedMs, answeredWith, answer, sameAsSegmentId }; clientId (a UUID) and at least one axis field or skipped: true are required. For a speaker or boundary item only clientId, answer, personId, sameAsSegmentId and skipped count, and answer is required (see above); a new answer to a line replaces its old one. correction is the whole corrected text of an item of one line; personId an existing person; answeredWith is swipe, button, sheet or key. 200 { labelId, superseded }: a new answer on an answered item supersedes its label (superseded, its id); the same clientId again returns the first answer's labelId and changes nothing; skipped returns the item to the queue, stores nothing and answers labelId: null. An answer after the lease lapsed counts. 404 for an unknown item
GET /golden/progress { days: [{ date, lines, answered, boundaries, boundariesAnswered }] }, one per day in Nytka__Eval__GoldenDays, oldest first: lines with text and answered of them with a speaker golden label (any answer, unsure too), boundaries the boundary items of the day and boundariesAnswered those answered. Empty days with none set
DELETE /labels/{labelId} Deletes the label and its kept clip and clears the marks it set; the label it had superseded is current again and its marks apply. 204, 404 for an unknown label
GET /labels?since=&task=&limit= Current labels created after since, oldest first, no audio: { items, nextSince }, limit 1 to 2000 (default 500), task a task of the axis kinds or import (golden labels are not listed). An item is { labelId, itemId, task, segmentIds, conversationId, startedAt, durationMs, source, deviceType, content, transcript, flags, language, personId, correction, overlap, answeredWith, provenance, createdAt, verdicts }; verdicts are the models' own verdicts on its lines, { segmentId, voiceIsUser, voiceSimilarity, speechGuess, speechScore }, never the rule's, which the label's mark decides. nextSince is the last createdAt of a full page, else null
GET /voice/pending { items: [{ labelId, segmentId, durationMs }] }: the lines of current me labels not yet confirmed, without the flags two_speakers or overlap
POST /voice/confirm Body { labelIds } (1 to 500): those lines teach the voiceprint, each 2 s or longer with its fingerprint still held, while Nytka__Voice__Learn is on. 200 { learned }, the lines learned; a label confirmed before teaches nothing again. 400 for an unknown label, with nothing changed
POST /import Body { provenance, items: [{ segmentIds, source, content, transcript, flags }] }: old label sets by segment id, provenance import: and a name. 200 { imported, skipped }: an item whose segments are not all there, span conversations, or that the same provenance imported before is skipped. Imports never set a mark

Webhooks

A webhook tells another tool, such as Home Assistant, n8n or a script, that something new exists. Add one in the app (Device, developer mode, Webhooks) or over the API:

curl -X POST https://nytka.example.com/api/v1/webhooks \
  -H "Authorization: Bearer $NYTKA_ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.example.com/nytka", "events": ["conversation.ready", "task.created"]}'

events names event types, or * for all of them except segment.created, which only a webhook naming it gets. The answer holds the signing secret, whsec_ and 43 characters, once: nothing shows it again, and there is no rotation, so delete the webhook and make a new one. The URL is http or https, at most 2,048 characters. Private addresses are allowed, because your receivers usually sit on your own network. At most 20 webhooks. PATCH changes url, events, format, description or active. POST /api/v1/webhooks/{id}/test sends a ping, even to an inactive webhook, and GET /api/v1/webhooks/{id}/deliveries lists the log.

Event Sent when data
conversation.ready A summary was stored, the first one and every re-run { id, startedAt, endedAt, title, summary, tasks: [{ id, text }], tags, transcriptTier }; transcriptTier is draft, final or mixed (Paid transcript pass)
task.created A summary produced a new commitment the task, as GET /api/v1/tasks shows it, without personId, personName and kind, and with due
task.completed A task was completed the task
memory.created A memory was added, by extraction or by hand { id, text, conversationId }
bookmark.created A bookmark was added { id, at, note, source }
person.fact.created A fact about a person was added, by extraction or by hand { id, personId, personName, text, basis, conversationId }; basis and conversationId are null for a fact you added
brief.ready A calendar brief was made for a meeting { id, title, startsAt, people: [{ id, name }], text }
segment.created A transcription batch stored its segments, about every 30 to 90 seconds while someone talks. Opt-in: * does not match it { conversationId, batchId, segments: [{ id, startedAt, endedAt }] }: ids and times only, never the text. Read the text with GET /api/v1/conversations/{id} and a read token; each segment there has isUser, the wearer's verdict (your mark, else the voice match, else the provider's)
digest.ready A daily digest was made, by the schedule or on demand { id, localDate, headline, overview }
transcript.upgraded A paid pass replaced draft lines with the engine's text (once per pass) { conversationId, passId, engine, replaced, inserted, tier }: counts only, never text; a summary follows with conversation.ready
usage.budget.warning The month's transcription spend reached stt.budgetWarningPercent of stt.monthlyBudgetUsd; once a month per budget { id, month, budgetUsd, spentUsd, at }; month is yyyy-MM in the user's time zone
usage.budget.reached The spend reached the budget: batches now go to stt.budgetModel (Usage and budget); once a month per budget as usage.budget.warning
ping You called test {}

A webhook gets nothing for events from before it existed, or while it is inactive. The change and its deliveries commit together, so a delivery exists exactly when the change did. A payload never carries a transcript, unless the webhook's format is omi (below): fetch what you need with a read token, for example GET /api/v1/conversations/{id}.

The omi format

"format": "omi" makes a webhook receive Omi's memory-creation payload, the body of Omi's "conversation created" trigger, so an app written for Omi can read it as it is. It is the one format that carries the transcript text: the owner chooses that by choosing the format. Such a webhook gets conversation.ready only (the first summary and every re-run, as above), so events may hold nothing else except *; the API answers 400 otherwise. A ping still arrives in the usual envelope. Signing (Nytka-Signature), the headers, retries, the log and the delivery limits are the same as for the default format; the body is the payload itself, with no envelope.

The body's id is the conversation's id and stays the same when a summary is re-run, so a receiver that drops repeats by it would drop the re-run. Omi's payload has no revision field. Dedupe on the Nytka-Delivery header instead: it is new for each summary and the same across retries of one.

{
  "id": "0198a1c4-7d2e-7b1a-9c3f-5e6d7a8b9c0d",
  "created_at": "2026-10-23T09:00:00Z",
  "started_at": "2026-10-23T08:58:00Z",
  "finished_at": "2026-10-23T09:00:00Z",
  "transcript_segments": [
    { "text": "Call Ben tomorrow.", "speaker": "SPEAKER_00", "speakerId": 0, "speaker_name": "Wearer", "is_user": true, "start": 0.0, "end": 2.4 }
  ],
  "structured": {
    "title": "Lunch with Anna", "overview": "…", "emoji": null, "category": null,
    "action_items": [{ "description": "Call Ben", "completed": false }],
    "events": []
  },
  "apps_response": [],
  "discarded": false,
  "folder_id": null,
  "folder_name": null
}
Omi field From Nytka
id the conversation's id
created_at, started_at, finished_at when the conversation was stored, began and ended (UTC, ISO 8601)
transcript_segments[].text the segment's text; segments marked media (a TV or a video) are left out
…speaker, speakerId SPEAKER_00, SPEAKER_01, … in order of first appearance; the wearer is one speaker, a voice or provider label another; segments with neither a voice id nor a label share one speaker, since nothing tells them apart
…speaker_name the label Nytka shows: Wearer, the named person, else the provider's label; null if none
…is_user the wearer's verdict (your mark, else the voice match, else the provider's; unknown reads as false)
…start, …end seconds from the conversation's start, by capture time
structured.title, overview the title and the summary
structured.action_items the conversation's commitments: description and completed
structured.emoji, category not in Nytka: null
structured.events Nytka does not extract calendar events from speech: always []
apps_response no apps run in Nytka: always []
discarded always false: Nytka delivers kept conversations only
folder_id, folder_name not in Nytka: null

Not supported: Omi's real-time transcript trigger (POST …?session_id=&uid=, a bare array of segments with text as someone speaks). Nytka's segment.created carries ids and times only and arrives per batch, so it does not map onto it. The uid query parameter Omi appends is not sent either. transcript.upgraded is not sent in this format yet.

{
  "id": "3f0d2c6e-5b1a-5c8e-9a47-0d1b7e2c9f10",
  "type": "task.created",
  "createdAt": "2026-09-29T18:14:36.4829131Z",
  "data": {
    "id": "018f0000-0000-7000-8000-0000000000a1",
    "conversationId": "018f0000-0000-7000-8000-000000000001",
    "conversationTitle": "Planning the move",
    "conversationStartedAt": "2026-09-29T17:02:11Z",
    "text": "Call the landlord about the key by Friday",
    "done": false,
    "doneAt": null,
    "createdAt": "2026-09-29T18:14:36.4829131Z"
  }
}

Check the signature. Each request carries Content-Type: application/json and these headers:

Header Value
Nytka-Event The event type
Nytka-Delivery The delivery's id
Nytka-Timestamp When this attempt was sent, in Unix seconds
Nytka-Signature v1= and the lowercase hex HMAC-SHA256 of {timestamp}.{body}, keyed with the secret

The signed text is the timestamp's digits, a dot and the exact bytes of the body; the key is the whole secret, whsec_ included, as UTF-8. Compute it over the raw body, before you parse the JSON: key order and spacing in the body are not fixed, so a re-serialized copy will not match. Compare in constant time. In Python:

import hashlib
import hmac
import time


def verify(secret: str, headers: dict, body: bytes) -> bool:
    timestamp = headers["Nytka-Timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        return False  # older than five minutes: a replay, or a stuck clock
    signed = timestamp.encode() + b"." + body
    expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, headers["Nytka-Signature"])

By hand, printf '%s.%s' "$TIMESTAMP" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" prints the digest to compare. Refuse timestamps more than five minutes old. Delivery is at least once and not ordered, so drop repeats by the payload's id.

Delivery and retries. A delivery is one POST with a 10-second timeout, and redirects are not followed. Any 2xx succeeds. Anything else, another status, a timeout or a refused connection, is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, six attempts in all, and the delivery ends failed. Every attempt is signed with a fresh timestamp. The log keeps the status, the attempts, the last HTTP status and a short error such as "HTTP 500" or "timeout", never a response body. It drops a payload once its delivery ends, and keeps the newest 200 deliveries per webhook.

MCP

Agents read Nytka over the Model Context Protocol at https://nytka.example.com/mcp. It speaks Streamable HTTP and keeps no session: POST only, and GET and DELETE answer 405. Every request needs a bearer token with scope read or admin; a missing, wrong or revoked one gets 401. A request with an Origin header, which a browser always sends, gets 403: MCP clients are not browsers. There is no OAuth, so a client that can only sign in through OAuth cannot connect.

Tool Input Output
list_conversations since?, before? (ISO 8601 with an offset, or a date), tag?, limit? (1 to 50, default 20) { items: [{ id, startedAt, endedAt, title, summary, preview, mediaShare, tags }], nextBefore }
get_conversation id (UUID), transcript? (default true), part? (from 1, default 1) { id, startedAt, endedAt, title, summary, tasks: [{ id, text, done, personId, personName, due }], transcript, truncated, part, parts, tags }
list_tasks status? (open or done), conversationId?, before? (a task id), kind? (commitment, the default, idea, waiting_on or all), limit? (1 to 200, default 50) { items: [Task], nextBefore }
list_topics kind? (tag or person), q?, limit? (1 to 200, default 50) { items }, as GET /api/v1/topics
list_notes topic?, conversationId?, before? (a note id), limit? (1 to 200, default 50) { items: [Note], nextBefore }, newest first (see Notes)
list_memories before? (a memory id), limit? (1 to 200, default 50), expired? (default false) { items: [Memory], nextBefore }; a memory past its expiresAt only with expired
list_bookmarks before? (ISO 8601 with an offset, or a date), beforeId? (UUID), limit? (1 to 100, default 30) { items: [{ id, at, note, source, conversationId }], nextBefore, nextBeforeId }
list_digests before? (a date, yyyy-MM-dd), limit? (1 to 100, default 30) { items: [{ id, localDate, headline, overview, highlights: [{ text, conversationId }], decisions, openQuestions, createdAt }], nextBefore }
search query, kinds? (a list of conversation, memory and person), tag?, limit? (1 to 30, default 10) { items: [Hit] }
list_people tag? { items: [{ id, name, lastSeenAt, facts, tags, named }] }, most recently heard first, then by name; facts counts live facts; named is false for a person known only by a role
list_rollups period? (week, month, year or all), topicId? (UUID), before? (date), limit? (1 to 100, default 12) { items, nextBefore }, as GET /api/v1/rollups
get_person id (UUID) or name (any case), one of the two the person page as GET /api/v1/people/{id} returns it, without hasVoiceprint and voiceprintSamples; a tool error ("No such person.") for an unknown one
get_ledger id (UUID) or name (any case), one of the two the promise ledger as GET /api/v1/people/{id}/ledger returns it; a tool error ("No such person.") for an unknown one
list_tags query? (names starting with it) { items: [{ name, conversations, people, uses }] }, most used first, as GET /api/v1/tags (see Tags)
get_quality from?, to? (dates, yyyy-MM-dd; the last 30 days by default, at most 366) { timeZone, from, to, days }, as GET /api/v1/quality, which needs an admin token while the tool takes any
get_topic id (UUID), before? (ISO 8601 with an offset), limit? (1 to 100, default 30) the page of GET /api/v1/topics/{id}
get_related kind (conversation, task, memory or note), id (UUID), limit? (1 to 20, default 5) { items }, as GET /api/v1/related; the description tells the agent a link is no evidence that anything in one moment is true of the other
get_insights none { daysWithAudio, trustedAfterDays, insights }, as GET /api/v1/insights, which needs a read or admin token like the tool
ask question (1 to 500 characters) { answer, sources: [Source] }, as POST /api/v1/ask (see Ask); a tool error when no model is set or it fails
list_events date? (yyyy-MM-dd, default today), tz? (an IANA zone, else user.timeZone) { date, events }, as GET /api/v1/events

These fourteen are read-only (readOnlyHint), declare an output schema and return their result as structured content and as JSON text. A tool returns the fields its REST endpoint returns, with the limits and defaults in the table (search has no offset). Pass nextBefore as before to read the next page. An unknown id is a tool error ("No such conversation."); a malformed id, time, status or kind or tag is JSON-RPC error -32602. The transcript has one line per segment, in UTC and without a speaker label when there is none: [HH:mm:ss] Speaker: text; a media line's speaker is Media and a call line's <label> (call). It is cut at a line boundary after 60,000 characters into parts. part picks one (from 1; one outside 1 to parts is -32602), parts counts them and truncated is true while a later part exists, so a client reads the rest with part: 2, 3 and so on. Only the first 20 parts are read. transcript is null when it is false.

Write tools (Plane NYTKA-81) need an admin token.

Tool Input Output
create_task text (1 to 200 characters), conversationId? (UUID), kind? (commitment, the default, idea or waiting_on), personId? (UUID), due? (ISO 8601) the created Task; a tool error for an unknown conversation or person, a conversation that already has a task with this text, or, without a conversation, an open task made by hand that has it
complete_task id (a task id) the completed Task, as PATCH /api/v1/tasks/{id} with { done: true }; a tool error for an unknown id
create_memory text (1 to 300 characters) the created Memory, as POST /api/v1/memories; a tool error when a live memory already holds the fact

A read token's tools/list never names these three, and calling one with a read token is JSON-RPC error -32600 ("Access forbidden: This tool requires authorization."): the MCP host's authorization filters check the same admin-or-read scope REST does, before the tool runs. create_task matches POST /api/v1/tasks (see Tasks); a task it creates is marked edited, so a later summary never removes or rewrites it, and it raises task.created only for a commitment, matching a summary's tasks.

Connect a client. Give it the URL and a read token (see Tokens and scopes). In Claude Code:

claude mcp add --transport http nytka https://nytka.example.com/mcp \
  --header "Authorization: Bearer nyt_..."

Or in a project's .mcp.json, with the token in an environment variable:

{
  "mcpServers": {
    "nytka": {
      "type": "http",
      "url": "https://nytka.example.com/mcp",
      "headers": { "Authorization": "Bearer ${NYTKA_TOKEN}" }
    }
  }
}

Any client that speaks Streamable HTTP and sends a fixed Authorization header connects the same way. Behind a reverse proxy, pass /mcp and that header through unchanged.