FishTea
Documentation

Running it

Nothing assumes localhost. The core binds 0.0.0.0 by default, so a fresh application answers on every address of the machine it runs on, and the start-up banner prints the addresses other machines should dial.

Addresses#

setting flag what it is
FISHTEA_HOST --host what the core binds. 0.0.0.0 (default) accepts any address; 127.0.0.1 keeps it local
PORT --port the core's port
FISHTEA_PUBLIC_HOST --public-host the address others dial. Detected from the LAN when unset; used for printed URLs
FISHTEA_CORE_URL where the ui server proxies /core (compose sets http://core:3000). The browser then dials the page's own origin, so the visitor's cookie is first-party wherever the page was opened
FISHTEA_CORS_ORIGINS --cors browser origins allowed to call the core. Unset means any port on an address the core answers on, which covers the UI. Comma separated, or *
DATABASE_URL --db Postgres, wherever it lives
CLASSIFIER_URL --classifier the needle pool, wherever it lives

Detection is a heuristic: a machine with several NICs, or one behind NAT, needs FISHTEA_PUBLIC_HOST set by hand. There is no TLS story: the core speaks HTTP and expects something in front of it.

CORS#

The origin is reflected, not starred: a credentialed request may not be answered with a wildcard, and reflecting anything would let any site make credentialed calls. So only origins on a hostname this core answers on are echoed back, which covers the UI on another port of the same machine with no configuration. FISHTEA_CORS_ORIGINS names others explicitly.

The stack#

fishtea up              # build and start db + classifier + core + ui
fishtea up --detach
fishtea ps
fishtea logs core
fishtea stop            # pause; start resumes it untouched
fishtea down            # stop and remove; the data volume is kept
fishtea down -v         # also delete the data

The classifier pool#

Production is the needle3 pool. The lexical classifier is a development harness and the benchmark baseline, never production.

Each worker holds one persona tool subset, because switching a worker between subsets costs a needle_init of 0.7–1.2 s against 125–310 ms for the classification itself. So NEEDLE_WORKERS must be at least the number of distinct persona subsets, three for every shipped archetype, which is the Compose default.

Every classify call has a hard budget (rules.classify.budgetMs). Past it the pour is ask, never a dumber brain.

Never put a CPU limit on the classifier. The engine starts twelve threads and keeps them, reads no environment variables, and ignores the affinity mask it is given, so a limit does not make it use fewer threads; it makes twelve spinning threads fight over fewer cores. Measured, one classify that takes 238 ms on sixteen cores takes 9.9 s pinned to eight and 92.7 s pinned to one. A --cpus setting or a Kubernetes quota on this container is the difference between working and not.

For the same reason workers compute one at a time: two classifies side by side are 6.5× worse total throughput than two in a row. Capacity is about four to five classifies a second per host, and the only way to scale out is more hosts.

What does move the needle is how much the model has to say. Latency is decode length (`latency ≈ 110 ms

Warm-up matters: a cold worker switching subsets costs 0.7–3.7 s depending on load. /reload warms in the background, one worker at a time, deliberately sequential, because parallel inits thrashed to 48 s.

Database#

Postgres 18 with pgvector. Embeddings are halfvec(3072) with HNSW indexes, because needle's embed() returns 3072 floats and pgvector's vector index caps at 2000 dimensions. FISHTEA_EMBED_DIM overrides.

fishtea migrate                       # additive
fishtea migrate --allow-destructive   # also drop what left the spec
fishtea seed

Migration is additive by default and refuses destructive changes unless asked. An enum is kept in step with the spec: a value the spec adds widens the column's CHECK on the next migrate (reported as changed), and a value it removes is refused unless --allow-destructive is passed, and even then only while no row still holds that value. A pgdata volume created by an older major version will not start under 18: dump and restore, or fishtea down -v for a throwaway stack.

When the classifier chooses nothing#

A small model sometimes reasons its way to the right shape and still emits no call, and a pool that is busy or switching subsets can miss the turn's budget. In both cases the core no longer gives up at once:

  1. the keyword classifier answers when it is sure (a trigger phrase, or a value it knows, like a mood);
  2. otherwise rules.classify.fallback, a value like { "shape": "findProducts", "arg": "text" }, answers with the visitor's own words as that argument: a shop searches its catalogue rather than saying "I didn't catch that". It must be a shape that reads;
  3. otherwise the ask, offering only shapes a chip can start (nothing that needs an id).

The pool is warmed with each persona's classifiable shapes, which is what a request actually sends, so a worker is already on the right subset; a classify: false shape is never one of them. The pool matches a call's name on letters alone (find_products is findProducts) and reports what it could not match as dropped.

Health and benchmarks#

fishtea doctor
fishtea bench                  # against a running core
fishtea bench -- --conventional

packages/bench/budgets.json holds the budgets; baseline.json holds the last recorded run. The conventional multi-page baseline currently measures server time only, with no browser parse or render, so it is not yet the task-completion comparison the blueprint asks for.

Admin#

/admin on the ui server is where the people who run an application sign in. It shows every visitor, their visits and each turn in full (what was said, what it matched, what the plan read and wrote, the layout the answer carried), the questions nobody could answer, the spec (edited whole, validated on save, refused if someone saved since, the replaced version kept in .fishtea/spec-history/), and the lighter settings: the scalar values in rules and each plugin's configuration. What guards visitors (rules.writes) and what is structure (the layout, the menu) stays in the spec editor.

The first start creates one operator, admin, with the password fishtea-admin (or FISHTEA_ADMIN_PASSWORD, if set when the operator is created). Signing in on it opens one thing only: a form to replace it. Until then the core says so at every start. A new password is at least 12 characters, not the one given and not containing the username; changing it signs out every other session of that operator.

passwords argon2id; the answer to a wrong username and a wrong password is the same
sessions 32 random bytes in an httpOnly, SameSite=Strict cookie (fishtea_operator), stored as a hash, 8 hours
lockout five wrong passwords lock the account for fifteen minutes; ten attempts a minute per address
changes a cookie'd change also needs the x-fishtea-admin header, which a page on another site cannot send

A lost password. fishtea admin reset (in a deployed image: bun packages/cli/src/index.ts admin reset, run where the core's DATABASE_URL is set) puts admin back on the first password, FISHTEA_ADMIN_PASSWORD or the one above, lifts a lockout and ends that operator's sessions. The next sign-in must change it again, so a reset hands nobody a working account. --username names another operator.

Jobs#

/admin → Jobs lists the application's operator shapes (spec.operator.shapes, less the console's own op.restart, op.editSection and op.notify), each with its inputs filled with their defaults and a Run button. A job that writes asks once more. The run starts at once (POST /op/jobs/:name answers 202) and carries on in the core, because a catalogue sync can take longer than a proxy lets a request wait; the page asks GET /op/jobs every few seconds until it ends, and shows what the shape answered or why it failed.

Every run is kept in fishtea.job_run, so the last good answer is still there after a restart, and what a job wrote stays in the application's tables until the next run replaces it. One run of a job at a time across every core on the database (a second press is a 409); a run a stopped core left behind is marked interrupted when a core starts.

On a schedule: an operator shape may say when the core runs it by itself.

"op.syncMoods": { …, "schedule": { "daily": ["05:00"], "afterDeploy": true, "args": {} } }

daily is times of day in UTC; one missed while the core was down runs when it comes back. afterDeploy runs the job once for a release that has not had a successful run of it yet, a release being spec.json, calls/, seed/ and the framework's version, so a new image fills what it needs and a plain restart does not (a failed one is tried again an hour later). fishtea serve keeps the clock; FISHTEA_SCHEDULE=0 turns it off, for a second core on the same database or a quiet debugging session.

Reports#

/admin → Reports (the page it opens on) draws what the application declares in spec.operator.reports: a page per report, a period picked above it (7, 30, 90 days, 12 months, or two dates), and panels, each one query call drawn one way.

"operator": { "version": "…", "reports": {
  "performance": { "label": "Performance", "doc": "…", "panels": [
    { "title": "The period", "call": "report.totals", "view": "tiles", "columns": ["visits", "clicks"] },
    { "title": "Clicks", "call": "report.daily", "view": "line", "x": "day", "y": ["visits", "clicks"] },
    { "title": "By brand", "call": "report.brands", "view": "bar", "x": "brand", "y": ["clicks"] },
    { "title": "Brands", "call": "report.brands", "view": "table", "format": { "rate": "percent" } }
  ] } } }

Every panel is asked at once and one that fails shows its error in its own place. GET /op/reports lists them; GET /op/reports/:name?from=&to= answers the rows, for anything that wants them without the page.

/op has two doors: a person's session, or FISHTEA_OPERATOR_TOKEN for scripts (the TUI, fishtea notify). New applications get a random token in .env; the core warns while it is change-me. There is no TLS here either. Behind anything public, put the ui server behind HTTPS, and the session cookie is marked Secure when the request says it came over it.

Seed files and /admin edits#

seed/<entity>.json is loaded on every start. By default it upserts: every field it lists is written again, so the file is the truth and an edit made at /admin → Data lasts until the next restart. An entity with "seed": "insert" only gains the rows that are missing; rows already there keep whatever an operator changed. Use it for rows people manage by hand (programmes, campaigns); a change to such a row in the seed file then has to be made at /admin too, or in a one-off fix-up.

Visitor accounts#

rules.accounts turns them on: visitors sign up with an email and password, or with Google, and keep a profile and saved items across devices.

"accounts": { "entity": "customer", "profile": ["name", "photo", "preferences"], "saved": { "call": "products.byIds" } }

Routes, all under /account and all refusing a change without the x-fishtea-visitor header: GET /me, POST /signup, /login, /logout, /password, PUT /profile, DELETE / (with { "confirm": "delete my account" }), GET|POST /saved, GET /google/start?next=/path, GET /google/callback.

Visitors that are not people#

A headless run, a layout check, a browser test: each is a visitor to the core, and a new one each time. Tooling says what it is with x-fishtea-tool (headless and fishtea present do), which is kept on the visit's envelope whatever envelope.accept says. fishtea visitors prune lists the visitors only tooling ever produced and, with --yes, deletes them and everything they said. A visitor with one visit from a person is kept.

Things that will bite you#