Kotoba

Where to start

What Kotoba isWho she is and where she runs, in one page
InstallingOne package, two commands
First runA key, a model, her first words
The approval gateHow she asks before she acts
The two voice modesLocal voice, or the agent tunnel
The soul fileChange who she is
RoadmapWhat grows next, and what was cut on purpose

Or a section

↑↓ move openesc close124 pages
All pages

Voice and avatar7 of 9

The Live2D model

About 5 minutes to read

On this page

No model ships with Kotoba. Searching the repository for *.model3.json returns nothing, and public/ contains no model folder.

That is a licence decision, not an oversight. The good free models — Live2D's own samples included — are licensed for use but not for redistribution. Kotoba may not hand you a copy. So the repository carries the expression contract and the code that adapts to any model, and the model itself arrives on your machine, from its author.

Everything except the face works with no model installed. The avatar card says none is installed and names the folder to put one in.

Where models live

~/.kotoba/models/          # measured: model_library.models_dir()

One folder per model. Override with KOTOBA_MODELS_DIR.

Outside the repository on purpose: public/ is not writable after an install. A pip install has no repository at all, and a container freezes its image.

Getting a model onto your machine

The offer

GET /api/models/default answers what the download would fetch and whose licence it is: the three fields of model_install.DEFAULT_MODEL, plus installed_dir for whatever the sample last unpacked into. The constant, read out of the module:

json
{"name": "niziiro-mao",
 "url": "https://cubism.live2d.com/sample-data/bin/mao/mao_en.zip",
 "licence": "https://www.live2d.com/en/learn/sample/model-terms/"}

A setup screen has to show both before it offers the button, since the reason nothing ships is that it may not be redistributed. The browser's first-run screen does: the button itself is the acceptance, "I accept those terms — fetch her", beside a link to Live2D's licence.

kotoba setup does not offer this. Measured: nothing anywhere in api/src/kotoba/cli/ mentions model installation. A terminal-only install finishes with no face, and this is the step that gives her one.

By hand, the same thing:

bash
curl -X POST http://127.0.0.1:8000/api/models/install/default \
     -H 'Content-Type: application/json' -d '{"accept_license": true}'

Without accept_license it answers 400 and the licence URL. Nothing else about that request takes a URL — the address is fixed in the code. An operator can point KOTOBA_DEFAULT_MODEL_URL at their own mirror, and it is checked exactly like the original: an outbound-request guard on the address, then a fresh check on every redirect hop, up to five.

Your own model, as a zip

bash
curl -X POST http://127.0.0.1:8000/api/models/install/upload --data-binary @my-model.zip

The body is the archive. A model is tens of megabytes, so it streams to disk as it arrives and no part of it is held in memory. The first-run screen has a drop zone that does the same thing.

Add -H "Authorization: Bearer $KOTOBA_WEB_PASSWORD" when a password is set.

By hand

Unpack into its own folder so the entry file lands somewhere like:

~/.kotoba/models/mao_pro/runtime/mao_pro.model3.json

Keep the licence file beside it. Then reload the page. No rebuild, no environment variable.

What a model must contain

A .model3.json. That is the whole requirement, and an archive without one is refused:

that archive holds no .model3.json, so it is not a Live2D model

Everything else follows from Cubism: it resolves textures, motions and expressions relative to that entry file, so the whole folder has to come with it and stay reachable. A model whose siblings 404 loads and then shows nothing at all.

Which entry is chosen when an archive holds more than one: shallowest first, then alphabetical by the path text. Alphabetical by text and not by path object, because Windows compares those case-folded and the same download would otherwise wear a different face there.

If the entry sits in a folder called runtime/ — which the official samples do — the parent becomes the installed folder, so the licence file stays beside the model and the entry stays within the depth the scanner walks (three levels).

What the installer refuses, and what it drops

Measured against install_zip:

Archive larger than200 MB
Unpacks to more than300 MB
More than4000 entries
A name longer than200 characters
An entry naming a file outside the folderrefused
An entry that is a link or a device, not a filerefused
No .model3.jsonrefused

File types that are written: .json .moc .moc3 .png .jpg .jpeg .webp .txt .md .wav .mp3. Anything else is skipped and counted, not refused — the archive still installs.

A measured install of a small test archive containing an evil.html:

json
{"dir": "demo", "entry": "runtime/demo.model3.json", "replaced": false,
 "skipped": 1, "skipped_kinds": [".html"], "files": 4, "bytes": 106,
 "path": "/…/models/demo"}

The .html never reached disk. The licence file beside the model did.

The archive is unpacked to one side and moved into place only once all of it has passed, under a lock, so two installs cannot interleave and a failure never leaves a half-written model. Reinstalling replaces: one model, one folder, the newer one.

How the files reach the browser

Kotoba serves them itself, read-only, under /api/models/raw/…, under their real paths so the entry file's own relative references resolve to sibling URLs.

Two refusals decide what leaves, and both were measured:

resolve("demo/runtime/demo.model3.json")  -> the real path
resolve("demo/runtime/evil.html")         -> None
resolve("demo/../../etc/passwd")          -> None

The type check runs on the resolved path, so it judges the file that would actually be opened rather than the spelling asked for. Servable types: .json .moc3 .moc .png .jpg .jpeg .webp. This is somebody's unpacked download being served on the app's own origin, and a stray .html or .js in it would run there.

Note the two lists are not the same. Four types are installed but never served.txt, .md, .wav, .mp3. Measured:

resolve("demo/runtime/motion.wav") -> None
resolve("demo/LICENSE.txt")        -> None
resolve("demo/runtime/t.png")      -> the real path

For the licence and the readme that is the intent: they belong on your disk, beside the model, not on a URL. For motion audio it is a gap rather than a decision — a model whose motions reference a .wav will have that file installed and will get a 404 if anything asks for it. In practice nothing does yet, because Kotoba disables the model's auto-played motions (see Lip sync and framing).

The first fetch carries ?token=, the only credential a browser can attach to it; the backend swaps it for a short-lived cookie scoped to /api/models/raw that the model's own relative fetches then ride. That cookie is derived separately from the file-viewer one, so a cookie minted to draw a face cannot read your files.

Choosing between several

GET /api/avatar reports what is installed and which one she wears. With exactly one installed, that one is used. With several, pick one in Settings → Personality → Avatar model, which applies on reload. Only something actually on disk is accepted — a stored value that 404s is a control that looks like it works and does not.

A configured model that nobody installed is the fresh-install case, not an error: the first model on disk stands in, because a face nobody asked for beats no face at all.

Which model she wears is a backend value (soul_config.avatar_model, seeded once from soul/default.md). It used to be a build-time environment variable, which meant a prebuilt image could never change model and no setup screen could choose one.