HTTP surface#
What somnia serve answers. The page is the only client this was written for,
but nothing about it is private to the page.
There is no authentication on any of it. Anything that can reach the port
can list your books, read the agent, spend your API credit, move your position,
change which book you are on, and start or stop hours of rendering.
Reachability is the authentication, which is why the server binds to localhost
and the only path in is tailscale serve.
Everything the page fetches lives under /api/, and that prefix does work: the
service worker knows never to cache it — the Cache API throws when asked to
store the 206 a seek produces — and it keeps these routes ahead of the static
mount, which would otherwise swallow them. / serves the PWA itself, and
everything outside /api/ goes out with Cache-Control: no-cache — which
means ask before you use it, not do not store it: unchanged, the ETag
answers 304; changed, the new file arrives at the next launch. Without it the
browser invents a freshness policy (Chrome’s is a tenth of the file’s age), and
what that looks like is a deploy that did not happen — new bytes on disk, every
request answered, last week’s page on the phone.
All times are milliseconds on the book clock: the render clock, counted in samples before encoding, and the same clock chapter marks, search results and saved positions all speak. It is not per-chapter, and it is not what a decoder would tell you.
Route |
Method |
Answers |
|---|---|---|
|
GET |
|
|
GET |
Which somnia is answering |
|
GET |
Every book, most recently listened to first |
|
GET |
One book, whole — or 404 |
|
GET |
The chapter’s audio — or 404 |
|
GET |
The first |
|
GET |
Where the sentence being spoken at |
|
GET |
The book’s own words at |
|
GET |
Books to add, from the local catalog (both libraries) |
|
GET |
The voices a book may be asked for in |
|
GET |
What is rendering, what is waiting, what went wrong |
|
DELETE |
Take a book away — rows, audio and all |
|
POST |
Make this the book a cold launch opens — or 404 |
|
POST |
Say the reader is done with a book, or that they are not |
|
POST |
Say what a book is called here, and who wrote it |
|
POST |
The agent’s reply, and a move if it made one |
|
POST |
Drops one conversation |
|
POST |
What became of a report — always 200 |
|
POST |
Ask for a book — always 200 |
|
POST |
Stop a render, or take a book out of the line |
GET /api/version#
{ "version": "0.8.dev76+g778d26abf" }
What somnia.__version__ says, which setuptools_scm writes from the checkout
the box was built from. The commit is the point: somnia-install.sh defaults to
main rather than the last release, so every box is expected to be ahead of the
tags, and a bare release number is the one form of this string that two
different deploys could share.
The page asks once, on the way in, and draws it at the foot of Settings. It asks
the server rather than reading a constant out of app.js because the two can
disagree — the service worker answers the shell from its cache when the network
is gone, so a page older than the box is an ordinary state, and the version that
decides what may be said is the box’s.
GET /api/books#
{
"last_gid": 271,
"books": [
{
"gid": 271, "title": "Black Beauty", "authors": "Sewell, Anna",
"status": "done", "total_ms": 22320000, "chapters": 49,
"chapters_total": 49, "position_ms": 11560000, "seq": 3,
"finished_at": null, "created_at": "2026-03-11 21:40:02",
"source": "gutenberg"
}
]
}
last_gid is what a cold launch opens. It is null only when nothing has ever
been played — the one moment it is fair to ask which book they want.
position_ms is null for a book never started, which is a different answer
from 0.
chapters is how many of the book’s chapters can be played now, counted from
rows that really exist, and 0 means there is nothing to open yet: a render
that has not produced its first chapter, or one that died before it. The books
panel draws its shelf from this list, and that is the field that decides whether
a row offers a press at all.
chapters_total is how many chapters the book has, which is a different
number from chapters on every book whose render has not finished — and the
one number the player, the reading now line, the shelf row and the book page
all read, so that they cannot disagree about how long a book is. 0 means
nobody wrote it down, which is true of anything rendered before the column
existed, and anything drawing it says nothing at all rather than “of 0”.
created_at is when somnia was first asked for the book. It is the only date on
this row that is about the reader rather than the render, it is what the
Workshop means by brought in, and it is what sorting the library by how new a
book is is built on.
finished_at is when the reader said they were done with the book, and null
while they have not — which is every book somnia has ever had until somebody
says otherwise. It is deliberately not status: that is the render’s word,
and a book somebody has finished reading would otherwise be indistinguishable
from one that was never made. A finished book is still a book somnia has and
still plays; the night shelf stops offering it, and the Workshop is where it
goes.
source is which of the two libraries the book came out of — gutenberg or
australia, the same two words a search result carries. Nothing stores it: it
is the gid read against the Australian offset, and it is said here so that the
book page can draw where from without keeping its own copy of a constant it
cannot see move.
The night shelf shows at most the twenty most recently touched books, and finished books and the one playing underneath are not among them and do not count towards the twenty. Everything is still in this answer — which books to draw is the page’s business, not the server’s.
GET /api/book/{gid}#
One round trip on purpose: the page needs all of this before it can put a finger on the play button, and two fetches at 2am on a tailnet is two chances to be left with a player showing nothing.
{
"gid": 271, "title": "Black Beauty", "authors": "Sewell, Anna",
"status": "done", "total_ms": 22320000, "chapters_total": 49,
"position_ms": 11560000, "seq": 3,
"chapters": [
{"idx": 0, "title": "01. My Early Home", "start_ms": 0,
"end_ms": 455000, "url": "api/audio/271/0"}
],
"stream_url": "api/stream/271/49", "stream_ms": 22320000
}
status is pending, rendering or done — how the page tells a book still
growing from a render that died. position_ms is where the book is, and since
ADR 10 it is also the line the spoiler guard is drawn at; there is no second
number, and the page has none to keep in step. Chapter url is relative,
because the app may be mounted under a path.
stream_url is the whole of what has been read of this book down one URL, and
stream_ms is how much book that is. The page loads it once and crosses every
chapter inside it without touching the media element — see GET /api/stream/{gid}/{n}. It is null when there is nothing to join, which is a
book with no audio yet; the per-chapter urls are always there beside it, and a
page that finds no stream plays the book a file at a time.
chapters_total is how many chapters the book has, against chapters,
which is how many can be played. While a render is going those differ, and the
difference is the only way to tell running out of audio three chapters into
thirty-nine — which is not the end of the book — from reaching the end of one.
It is 0 for every book rendered before the column existed, and 0 means
nobody wrote it down, so say nothing rather than “3 of 0”.
404 for a book that is not there.
DELETE /api/book/{gid}#
{"ok": true, "found": true, "said": "Black Beauty is gone, with everything rendered of it."}
The only route in somnia that takes something away for good, and it takes all
of it: the books row, the chapters, the indexed chunks and their vectors,
every queue row the book ever had, the m4a files with the folders above them
once those are empty, and the joined streams under data_dir. Half of that
would be worse than none of it — a shelf entry that plays silence, or hours of
audio nothing will ever mention again.
DELETE, and it means it, which is the difference from POST /api/queue/{id}/stop: that one is a POST because the row it names survives it.
Nothing survives this and nothing behind it is an undo, so the page asks twice
before it gets here.
200 with "ok": false for the two refusals, in the shape the queue’s
routes already answer in — a refusal is an answer, and said is the sentence
to show for it. A book with a live queue row is refused because a render is
about to write chapters back into the folder this would be emptying, and the
sentence names the job to stop first; queue.stop is keyed on the job id, so
it is a different number from the one just deleted. And a book with a chapter
whose audio_file lies outside SOMNIA_LIBRARY_DIR is refused whole rather
than in part — the same containment rule GET /api/audio/{gid}/{idx} applies,
and a path outside the library means the database has been carried between
machines or edited by hand. Which chapter it was is in the journal, not in said: it is an absolute
path on the VPS and this is read on a phone.
404 only for a gid that is not here at all, which is a page holding an id
from a database that has moved on. found is what tells the two apart, and the
body carries said either way.
A chapter whose file has already gone is not a refusal. There is nothing there to delete, and stopping at the first gap would leave the rest of the book orphaned for good.
POST /api/book/{gid}/open#
{"gid": 271, "position_ms": 11560000, "seq": 3}
The whole of switching books, and it writes one column: position_at. Since
last_gid is simply the book with the newest one, making a book the most recent
is choosing it — there is no new state, and no second place a position is
remembered.
Nothing else on the row is touched. position_ms stays where the last report
put it, which is what makes the book resume exactly where it was left;
and position_seq stays because that counts agent moves and nothing else, so the
page’s next report is accepted rather than refused and the listener is not
dragged anywhere. The two numbers in the answer are the book’s own, from before
the press — they say where the page is about to resume, and the page then asks
for the manifest anyway.
The timestamp written is a couple of seconds ahead of every other book’s rather
than simply datetime('now'). That column counts whole seconds and a tie is
broken by which book was added first, and the write this has to beat is the
page’s parting report for the book it is leaving, which lands milliseconds
later — without the lead, a reload could open the book you had just left.
404 for a book that is not there, and for a book with no audio yet — a render still on its first chapter, or one that died before it. Both are the same answer to a press: there is nothing to open. The guard is here and not only on the page because a book nobody can play made the most recent one would leave the next launch waiting on a render instead of on the book that was playing.
POST /api/book/{gid}/finished#
{"finished": true}
{"ok": true, "found": true, "said": "Black Beauty is finished."}
One column, books.finished_at, written as a UTC stamp or cleared. A body that
says nothing means true, which is the press that exists; {"finished": false}
is the undo, and it is the same route on purpose — an undo shaped like the doing
is what lets the day screen offer one control that toggles rather than two that
can disagree about a book.
Nothing else changes. The book keeps its position, its audio, its rows and its
status, and it still plays if it is opened. That is the whole distance between
this and the DELETE on the path above it, and it is why this one is not asked
about twice: marking the wrong row costs one press back.
POST rather than DELETE because nothing is deleted, and not on the agent at all — a hold-to-talk request at 2am is the wrong way to say a book is over.
404 for a gid that is not here, the same answer as the GET and the DELETE on this path.
POST /api/book/{gid}/name#
{"title": "Beauty, the horse", "authors": "Sewell, Anna"}
{"ok": true, "found": true, "said": "It is called Beauty, the horse now.",
"title": "Beauty, the horse", "authors": "Sewell, Anna"}
Two plain columns, books.title and books.authors, and a third that is the
point of the route: books.renamed_at records that a person has had an opinion
about this name, and ingest_book’s upsert reads it and leaves the pair alone
from then on. Without that, re-rendering a book — which is the ordinary way to
restart a render that died — put the catalog’s name back hours later with
nobody watching.
Both columns in one request because a name and an author are one edit on the screen that makes it, and two routes would let a phone that lost the tailnet between them leave a book with half the change on it. A missing field means the empty string rather than “leave it alone”, which is what a form that has been cleared actually says.
The stored strings come back, trimmed, so the page can draw what was saved rather than what was typed.
200 with "ok": false for a book asked to have no title at all. Every
screen names a book by title and falls back to book 1342 for a book the
catalog never named, so a blanked one would be indistinguishable from a book
that was never named — a rename that reads as a bug. An empty authors is
stored: plenty of books really do not have one.
The audio does not move. Chapters are found by the absolute path in their own row, so a renamed book goes on playing out of a folder named after whatever it was called on the day it was rendered.
404 for a gid that is not here, the same answer as the GET, the DELETE and the finished route on this path.
GET /api/audio/{gid}/{idx}#
The audio, as audio/mp4. Range, If-Range and 416 are handled, so seeking
works. No filename is offered — this is something to play, not to download.
404 if there is no such chapter, and also if the row points at a file that has
gone or resolved outside SOMNIA_LIBRARY_DIR. The second case is a warning in
the journal; on the phone both look like one chapter that didn’t arrive.
The media type is pinned rather than guessed. Python does not know .m4a and
the container image has no mime table, so guessing yields
application/octet-stream and Safari refuses to play the book — a bug that
cannot reproduce on a development machine.
GET /api/stream/{gid}/{n}#
The book’s first n chapters joined into one audio/mp4, with the same Range,
If-Range and 416 handling as a chapter, and the same pinned media type for the
same reason.
n is a version rather than a length: it names the chapters the file holds, so
a book that grew while somebody was listening is offered a new url and the
file their phone has open is never rewritten under an in-flight range request.
Versions are built on the first ask — a second or two of ffmpeg, -c copy, so
not a byte of audio is re-encoded — and kept under SOMNIA_DATA_DIR/streams,
never in the library. The library holds what a render produced, one file per
chapter, and every one of those paths is in the database; a join is a cache
that can be deleted at any time and rebuilt in a second or two, so it is kept
where nothing has to tell the two apart.
404 if the book has fewer than n chapters, if any of their audio has gone, or
if the join could not honestly be made. The reason is in the journal. The page
still has a url for every chapter, so a book with no stream is one that rebuilds
the lock screen at every boundary — a worse night, not a lost one.
GET /api/sentence/{gid}/{ms}#
{"gid": 271, "ms": 11560000, "start_ms": 11554300}
Where the sentence being spoken at ms began. The page asks when someone
pauses, never when they press play: a resume has to be instant, and a phone
that has been face down for an hour is the least likely thing on the tailnet to
answer quickly.
GET /api/passage/{gid}/{ms}#
{"gid": 271, "ms": 11560000, "text": "…"}
The only route that hands back the book’s own words, for the you are here row on the list of places — every other row on that screen carries its words down with the answer that named it.
text is null when there is nothing to say: no such book, a book whose text
was never indexed, or a book nobody has played a second of. The row then offers
no reveal, which is what it did before this existed.
The bound is inside the statement: the row must satisfy
start_ms < position_ms, applied to the row and not to the argument. Ask about
a point an hour past where the book has got to and the answer is the last
passage behind them — not a refusal, which is a frontier to read off.
The words are cut to 240 characters, the same limit as the places the row sits
among.
GET /api/catalog#
{
"query": "black beauty",
"entries": [
{"gid": 271, "title": "Black Beauty", "authors": "Sewell, Anna",
"have": "done", "source": "gutenberg"}
]
}
An FTS5 search of the local catalog — the copy somnia catalog-update writes —
so there is no round trip to Gutenberg and no wait. language defaults to en.
Punctuation is the caller’s own: terms are quoted before they reach FTS5, so an
apostrophe is a search rather than a syntax error.
At most eight entries. Eight is what fits on a phone above a raised keyboard, and a list that has to be scrolled to be read is a second screen wearing a hat; someone who cannot see the book they meant should type more of its name.
have is what somnia already thinks of that gid — done, rendering or
pending from the book itself, queued or rendering from a live queue row —
and null if it has never heard of it. A live queue row wins, because a
pending book that has just been asked for again is coming. It travels with the
row so a book that is already on its way is marked rather than offered and
then refused.
source is which library the book came from — gutenberg, or australia for
Project Gutenberg Australia, whose ids start at
900,000,000. The page names the second one on the row and says nothing for the
first. It travels for the same reason have does: the two libraries clear
their books against different countries’ copyright law, and that is worth
knowing before the press rather than after it.
GET /api/voices#
{
"voices": [
{"id": "af_heart", "name": "heart", "says": "American, warm and unhurried"},
{"id": "bm_george", "name": "george", "says": "British, a man, low"}
]
}
The six voices a book may be asked for in, in the order the page draws them —
the first is the default, and the same one SOMNIA_VOICE starts at. Served
rather than written into app.js so that the list the page offers and the list
POST /api/queue will accept cannot come apart: a pill offering a voice the
route would refuse is a press that does nothing.
id is Kokoro’s own name and the only form that reaches the model or the
database. name is what to draw. says is one line for anybody the sample
cannot reach — a phone on silent, a screen reader, a clip that did not arrive.
Cached for a day. It changes when somnia is deployed and not otherwise. The
samples themselves are static files under /voice/{id}.m4a, outside /api/,
and the service worker treats them like the rest of the shell.
GET /api/queue#
{
"items": [
{"id": 7, "gid": 271, "title": "Black Beauty", "authors": "Sewell, Anna",
"voice": "bm_george",
"state": "rendering", "place": 0, "chapters_done": 4,
"chapters_total": 49, "rendered_ms": 1840000, "stopping": false,
"responding": true, "error": "",
"submitted_at": "2026-08-05 22:14:03", "started_at": "2026-08-05 22:14:11"}
]
}
Everything worth showing, in one request: what is rendering, then what is
waiting in the order it will be taken, then what ended in the last 24 hours.
After a day a failure is history rather than news, and history is in
journalctl --user -u somnia-worker — which is why nothing here has to be
dismissed.
state is queued, rendering, done, cancelled or failed. That is the
queue’s own vocabulary and is not books.status, which is still exactly
pending, rendering, done.
place is the rank among the books that are waiting, and 0 for anything that
is not waiting — the one being rendered has left the line rather than being at
the head of it. chapters_done is counted from chapters that really exist,
because a chapters row is written only once its audio does. chapters_total is
0 until the parse finishes, and 0 means unknown. stopping is a render that
has been asked to stop and will, at the end of the sentence it is reading.
responding is worked out from the heartbeat when you ask, and is stored
nowhere: it is false for a render that has gone quiet for five minutes, which
is the only way a crashed renderer can be told from a slow one. It is honest
even when the worker unit has been stopped and there is nobody left to write
anything.
voice is what the request asked for, and "" for one that asked for nothing —
which is what the agent submits, and what every row written before the column
existed holds. Empty means the renderer’s own, and this process cannot see
what that is, so nothing here guesses at it.
POST /api/ask#
{"token": "…", "question": "where does the horse get hurt?", "gid": 271}
token is minted by the page when it starts and keys a conversation held in
memory; nothing is written to disk. token and question are required — 400
otherwise — and a turn that fails is a 500 whose body says Something went wrong
down here.
gid is the book the page has open, and it is optional. It is named to the
model on the end of the system prompt, once per turn, so that a question over a
playing book is not answered with which book do you mean? — which is what
happened while it was missing, on every turn, because the model could list the
shelf and nothing told it which of them was making the sound. Anything that is
not a positive integer — absent, null, a string, a bool — is taken as “no book
open” rather than refused, so a page that has opened nothing can still ask, and
a cached older app.js keeps working.
It is sent per turn rather than fixed at the start of a conversation: the page can open another book between two questions, and a conversation that remembered the first one would answer the second about the wrong book.
{"reply": "…", "move": {"gid": 271, "position_ms": 9930000, "seq": 4}}
move is present only when the book actually moved, so the page reads the key
rather than its contents. The sequence number travels with the position because
adopting one without the other would have the page’s next report refused.
This is a head start, not the mechanism: if the reply never arrives, the same move lands within fifteen seconds as the refusal of the page’s next report.
{"reply": "…", "candidates": {
"gid": 271, "title": "Black Beauty", "position_ms": 11560000,
"places": [{"chunk_id": 812, "start_ms": 9930000, "chapter_idx": 31,
"chapter_title": "32. A Horse Fair", "ahead": false, "text": "…"}]}}
candidates and move never appear together — a list and a seek in one reply
would move the book under somebody still choosing. Read by presence, like
move. position_ms is null for a book never started, and the page draws no
you are here row rather than inventing one. ahead is decided on the server,
by the same code that owns the spoiler guard: a row with ahead: true is drawn
covered up — words and chapter title both — until they ask, and the page
computes nothing.
POST /api/forget#
{"token": "…"} → {"ok": true}. Drops that conversation, which is what
Start over does when the agent has the wrong end of a mumbled question.
POST /api/position#
The only position write the page makes.
{"gid": 271, "position_ms": 11560000, "seq": 3, "reason": "tick"}
There was a played_ms here until ADR 10 — sound that really came out of the
speaker since the last report, which was what a report had to prove before the
high-water mark would follow it. Nothing reads it now. A page that still sends
one is taken at its word about the position anyway, which matters because a
phone holds the app it last loaded for as long as it likes.
reason is one of load, play, tick, seek, chapter, pause, hidden,
unload, ended, switch. An unknown one is taken as a tick and noted in the
journal. Nothing branches on which one it is: the list is a vocabulary rather
than a decision, kept so that a word nobody wrote can be noticed on the way in.
Always 200, in one of three:
{"accepted": true, "gid": 271, "position_ms": 11560000, "seq": 3}
{"accepted": false, "gid": 271, "position_ms": 9930000, "seq": 4, "reason": "moved"}
{"accepted": false, "gid": 271, "reason": "gone"}
A refusal is not an error. It is how the page is told the agent moved the book
while it was not looking, and it carries where to go instead. A 409 would put a
red line in the console at 2am for something working exactly as designed, invite
a throw in the fetch wrapper that skipped the one line that mattered, and be
unreadable to the sendBeacon sent as the page dies.
reason is what the page acts on. moved means the agent took the book
somewhere while the page was not looking, and the body says where. gone means
the row is not in this database any more — a page left open on a book that was
deleted — and there is nothing to go to. Nulls are dropped rather than sent: a
report about a book that is gone has no position to talk about, and
"position_ms": null would read as one.
400 is reserved for a body with no gid or no position_ms.
POST /api/queue#
{"gid": 271, "voice": "bm_george"}
Always 200, in one of two shapes:
{"ok": true, "id": 7, "said": "Black Beauty is next to be rendered."}
{"ok": false, "id": 0, "said": "Black Beauty is already here, all of it."}
said is a sentence to show somebody — it is the same string
Library.add_book gives the agent, out of the same function, so the page and
the voice cannot disagree about what just happened. A refusal is an answer, not
an error, for the reason /api/position gives above; here the answer is the
sentence saying why.
Two things are refused: a book with a live queue row, which is already coming, and a book somnia has all of. A render that died, was stopped, or was killed by a deploy is accepted — that is the retry that used to be impossible.
Nothing is fetched here. Whether Gutenberg has this book, and has it as HTML, costs a round trip and a parse, and a control that thinks for three seconds reads as broken — so an unknown gid is taken and fails minutes later in the worker with a sentence saying which of the two it was.
voice is optional and is held to the roster — the one thing
this route checks that the queue itself does not. Omitted, the render uses
whatever the renderer is configured with; named, it is written on the row and
survives the hours between the press and the render, whichever process gets
there. A name off the roster is 400, because it can only be a page left open
across a release or somebody with curl, and a book is six hours — too long to
find out afterwards that a typo was quietly rendered in the default.
400 is otherwise reserved for a body with no positive integer gid. Nothing
starts a render in somnia serve: this writes one row, and the somnia-worker
unit drains it one book at a time — see
ADR 5.
POST /api/queue/{id}/stop#
{"ok": true, "state": "cancelled",
"said": "Black Beauty has been taken out of the queue."}
Keyed on the queue row and not on the book: a gid owns several rows over its life — every attempt that failed or was stopped stays as the record of itself — so stopping by gid could reach into last week’s.
A job that was only waiting is cancelled by the time this answers. A job that
is rendering is still rendering: nothing reaches into that process, it only
raises a flag the render notices between sentences, so it stops at the end of
the one it is reading, up to about twenty seconds later. Every chapter already
finished stays playable.
POST rather than DELETE, and the row does not go away — it becomes the record of a render somebody stopped.
200 with "ok": false for a job that has already ended, which is a button
pressed a second too late. 404 only for a job that does not exist, which is a
page holding an id from a database that has moved on; its body carries said
too.