# Talos

**A backend you describe instead of write.** Declare your tables, write the
rules in plain language, map each URL to a sentence — a small language model,
built for this and running on your CPU, turns each sentence into a query plan,
and the runtime runs it safely against your database.

```ts
import { defineApp, defineRoutes } from '@athenox/talos'

const app = defineApp({
  prompt: `
    show the recipes.
    a member may add a recipe, and change or delete their own.`,
  schema: { tables: [{
    as: 'recipes', table: 'recipes',
    columns: { id: 'id', title: 'title', course: 'course', minutes: 'minutes', author: 'author_id' },
    enums: { course: ['main', 'starter', 'dessert'] },
    roles: { write: ['member', 'admin'] },
  }] },
})

// member (a guard) and asMember (the caller's role): see Accounts below
export default defineRoutes(app, {
  'GET    /api/recipes':     { say: 'show the recipes', orderBy: '-id', limit: 20 },
  'GET    /api/recipes/:id': { say: 'show the recipe {id}', one: true },
  'POST   /api/recipes':     { say: 'add a recipe', guard: member, ctx: asMember },
  'DELETE /api/recipes/:id': { say: 'delete the recipe {id}', guard: member, ctx: asMember, where: (_, me) => ({ author_id: me.id }) },
})
```

No SQL, no controllers, no migrations — and no database to set up if you do
not want one: without a `store`, Talos keeps its own SQLite file built from the
schema.

- **The model plans, the runtime decides.** The model reads the schema, the
  rules and the request, and generates the plan (operation, table, filters,
  values) by pointing into what it read. Permissions, ownership, validation and
  the SQL are ordinary deterministic code.
- **Compiled once.** A route's sentence goes through the model on its first
  call; the plan is kept with its parameters as slots and replayed without the
  model afterwards. A route then costs what its SQL costs.
- **A full backend kit around it**: accounts and sessions, uploads with image
  processing, full-text search, recommendations, feeds and sitemaps, CSV import,
  rate limiting, schema sync for SQLite and MySQL/MariaDB, Drizzle schemas.
- **Free-text requests too.** `app.run('the easy desserts under 30 minutes', ctx)`
  answers a sentence nobody wrote a route for.

**Contents** — [Install](#install) · [Guide](#guide): [the app](#1-the-app) ·
[the schema](#2-the-schema) · [routes](#3-routes) · [free text](#4-free-text-requests) ·
[accounts](#5-accounts) · [files](#6-files-and-images) · [search](#7-search-similar-rows-recommendations) ·
[databases](#8-databases) · [telemetry](#9-telemetry-ids-and-feedback) ·
[Results](#results) · [Environment](#environment) · [Full documentation](#full-documentation)

## Install

```sh
npm install @athenox/talos
```

Node 22.13 or later. The model (≈ 25 MB with its text embedder, ONNX, int8, CPU) ships in the package.
It comes with `h3` 1.x for its HTTP helpers (the version Nuxt 4 and Nitro
use, so they share it); `sharp` is needed only to process images. With Nuxt, keep Talos out of the
bundle — it loads its model from its own folder:

```ts
// nuxt.config.ts
nitro: { externals: { external: ['@athenox/talos', 'onnxruntime-node', 'sharp'] } }
```

# Guide

## 1. The app

```ts
export const app = defineApp({
  prompt,          // the rules, in prose — read by the model with every request
  schema,          // { tables, functions? }
  store?,          // your database; left out: Talos's own (.talos/data.db)
  seed?,           // rows for Talos's own database, put into tables still empty
  actions?,        // implementations of the schema's functions
  telemetry?,      // what this instance reports (on by default), or false
})
```

**The prompt** is written the way you would explain the app to a colleague:
what can be shown, who may do what.

```
show the recipes, the cooks and the comments.
a member may add a recipe, and change or delete their own.
an admin may do anything.
```

Prompts, route sentences and requests can be in **English or French** — the
model reads both, and a French request reaches an English schema through
`says` (`says: ['recettes']` on `recipes`).

The prompt guides the plan; it grants nothing. What a caller may do is the schema's
`roles` and your route guards — a model that is right 99 % of the time is not a
permission check.

## 2. The schema

```ts
{
  as: 'recipes', table: 'recipes',                    // the model's name, the physical table
  says: ['dishes', 'recettes'],                       // how people call it (any language)
  columns: { id: 'id', title: 'title', course: 'course', minutes: 'minutes', author: 'author_id', date: 'created_at' },
  columnSays: { minutes: ['time', 'duration'] },
  enums: { course: ['main', 'starter', 'dessert'] },  // declared values
  types: { minutes: 'number' },
  relations: { author: { table: 'users', on: 'author_id', columns: { id: 'id', name: 'name' } } },
  lists: { comments: { table: 'comments', on: 'recipe_id', columns: { id: 'id', body: 'body' }, orderBy: 'created_at' } },
  tallies: { likes: { table: 'likes', on: 'recipe_id' } },
  searchable: ['title'],
  rules: { title: { required: 'insert', trim: true, min: 3, max: 120, message: 'A title of 3 to 120 characters' } },
  defaults: { insert: { created_at: () => new Date().toISOString() } },
  children: [{ table: 'likes', on: 'recipe_id', cascade: true }],
  hidden: [],
  roles: { read: ['guest', 'member', 'admin'], write: ['member', 'admin'], columns: { featured: ['admin'] } },
}
```

The keys of `columns` are **logical** names — what the model reads; the values
are **physical** — what the database has and what rows come back with. Keep
them equal and in English when you can (the model knows English schema words
best) and put the words people use in `says` / `columnSays`.

| group | fields |
|---|---|
| read by the model | `as`, `columns`, `enums`, `flags` (booleans as two words), `says`, `columnSays` |
| reading | `relations` (an object per row), `lists` (an array per row), `many` (through a join table), `tallies` (counts), `computed`, `hidden`, `searchable`, `view`, `types`, `json`, `read` |
| writing | `rules`, `defaults`, `hash`, `slugs`, `files`, `infer` (filled by meaning), `writable`, `children` (cascade / restrict), `after` (functions to run), `write` |
| permissions | `roles` (`read`, `write`, `create`, `remove`, `columns`), `ownerColumn` + `ownerFrom` (rows pinned to the caller) |

Rules: `required` (`true` or `'insert'`), `min`, `max`, `email`, `pattern`,
`trim`, `unique` (or its message), `uniqueWith`, `message`. A failed rule is a
`400` with `data.errors: [{ msg, column }]`, the shape forms expect.

**Functions** are side effects the prose may name:

```ts
schema: { tables, functions: [{ name: 'sendNewsletter', params: ['subject'], required: ['subject'], roles: ['admin'], says: ['send the newsletter'] }] },
actions: { sendNewsletter: async ({ args, ctx }) => ({ sent: 120 }) },
```

→ every field: [docs/schema.md](https://unpkg.com/browse/@athenox/talos@latest/docs/schema.md)

## 3. Routes

### A sentence

```ts
const del = app.route('delete the recipe {id}', { ops: ['DELETE'] })
const { status, result, plan, callId } = await del({ id: 3 }, { userId: 7, role: 'member' })
```

`{k}` is a parameter, `[... {k} ...]` is dropped when `k` is missing,
`{k:no|yes}` says a boolean with one of two words. The first call asks the
model; later ones replay the plan with new values. Before the model reads a
sentence, a misspelt word of the app or a misspelt verb is corrected, and an
opening verb (*delete*, *add*, *change*, *show*, and their French forms) fixes the operation.

### A whole API as a table

```ts
export default defineRoutes<Member>(app, {
  'GET /api/recipes': {
    say: 'show the recipes', orderBy: '-date', limit: 12, total: true,
    fields: ['id', 'title', 'course', 'likes', 'author.name'],
    where: p => (p.course ? { course: p.course } : {}),
    shape: p => ({ search: p.q, page: Number(p.page) || 1 }),
  },
  'GET /api/recipes/:id':    { say: 'show the recipe {id}', one: true, notFound: 'No such recipe' },
  'POST /api/recipes':       { say: 'add a recipe', guard: member, ctx: asMember, stamp: (_, me) => ({ author_id: me.id }) },
  'PUT /api/recipes/:id':    { say: 'change the recipe {id}', guard: member, ctx: asMember, where: own },
  'DELETE /api/recipes/:id': { say: 'delete the recipe {id}', guard: member, ctx: asMember, where: own },
  'POST /api/auth/login':    users.login,              // any h3 handler sits in the table as it is
}, { guards: { member }, defaults: { firstError: true } })
```

- The HTTP method fixes the operation (GET → read, POST → create or call,
  PUT/PATCH → update, DELETE → delete).
- URL segments (`/:id`, `/:slug`) name the row; they replace whatever the
  model wrote on those columns. The body's fields are written by name.
- `guard` runs first and its user is given to `ctx`, `where`, `stamp`, `shape`
  and `reply`. `ctx` is what roles are checked against.

| option | |
|---|---|
| `say` | the sentence |
| `guard` | `(event, params) => user`; throws to refuse |
| `ctx` | `user => ({ userId, role })` |
| `where` | conditions set by code: `{ col: v }` or `(p, user) => ({...})`; `{ gte }`, `{ lt }`, `{ between }`, `{ ne }`, `{ in }`, `{ null }`, `any: [...]` (or), `'relation.column'` |
| `stamp` | columns the route writes itself: `(p, user) => ({ author_id: user.id })` |
| `fill`, `only`, `form` | defaults for missing parameters, allowed body fields, HTML-form checkboxes and empty fields |
| `shape` | per-request options: `(p, user, event) => ({ search, page, ... })` |
| `fields`, `row`, `reply` | what the answer holds |
| `notFound`, `firstError`, `pin`, `download` | 404 message, one error only, which segments name the row, send a file |
| `orderBy`, `limit`, `page`, `total`, `one` | `'-date'`, `['-likes', 'title']`, `'minutes~'` (empty last); `total` adds `{ total, page, limit }` |
| `search`, `find`, `similar`, `recommend` | ranking — [section 7](#7-search-similar-rows-recommendations) |
| `lists`, `counts`, `sums`, `returning` | which lists to load, extra counts and sums, return written rows |
| `rows`, `upsert`, `ignore`, `dedupe`, `before` | bulk inserts, insert-or-update, skip duplicates, remove duplicates, a hook before the write |

**Answers.** `200` with the row (`one`), the rows, `{ rows, total, page, limit }`,
`{ id }` for a creation, `{ success: true }` for an update or delete — or what
`reply` returns. `400` for failed rules, `401`/`403` from guards and roles,
`404` when nothing matched, `422` when the request is not something this app
does. Every answer carries `x-talos-plan` (the sentence and its plan — read it
once when you add a route) and `x-talos-call` (the model call).

→ [docs/routes.md](https://unpkg.com/browse/@athenox/talos@latest/docs/routes.md)

## 4. Free-text requests

```ts
const { status, result, plan, callId } = await app.run('the easy desserts under 30 minutes', ctx, { ops: ['SELECT'], limit: 10 })
```

`run` asks the model every time: a search box, an assistant, an admin console.
The same roles and ownership apply. `decide(sentence, ctx)` gives the plan
without running it; `execute(plan, ...)` runs a plan however it was made.

## 5. Accounts

```ts
import { accounts, hashPassword, verifyPassword } from '@athenox/talos'

export const users = accounts<Member>({
  store: app.store, table: 'users', expose: ['id', 'name', 'role'], signUp: ['name'], minPassword: 8,
  hash: hashPassword, verify: verifyPassword,
  session: { set: (e, user) => setUserSession(e, { user }), get: async e => (await getUserSession(e)).user, clear: clearUserSession },
})
export const member = users.guard()                                              // the account, or 401
export const asMember = (me: Member) => ({ userId: me.id, role: me.role })
export const own = (_: unknown, me: Member) => (me.role === 'admin' ? {} : { author_id: me.id })
```

Handlers: `users.register`, `users.login` (JSON or a plain HTML form),
`users.logout`, `users.me`, `users.change`, and `users.forgot` / `users.reset`
with `reset: { table, owner, send }`. `users.guard(check?)` re-reads the account
(a disabled one is refused). Any session store works; above, nuxt-auth-utils.

→ [docs/accounts.md](https://unpkg.com/browse/@athenox/talos@latest/docs/accounts.md)

## 6. Files and images

```ts
files: {
  photo: {
    dir: 'uploads/recipes', url: '/uploads/recipes/', accept: /^image\//, maxBytes: 12_000_000,
    image: { width: 1600, height: 1600, format: 'webp', quality: 82 },
    variants: { thumb: { width: 640, height: 480, fit: 'cover', format: 'webp' } },
  },
}
```

A multipart request carries the file under the column's name. It is checked,
resized, re-encoded, stripped of its metadata (a phone photo carries its GPS
position), stored under a random name, and the column set to its URL; variants
go to their own columns. Replacing it or deleting the row removes the old
file. Serve them with `serveFiles('uploads/recipes')`.

→ [docs/files.md](https://unpkg.com/browse/@athenox/talos@latest/docs/files.md)

## 7. Search, similar rows, recommendations

They rank the rows the plan chose — conditions and permissions still apply —
with words and with meaning (Talos's own French/English text embedder).

```ts
shape: p => ({ search: p.q })                                            // over `searchable`
shape: p => ({ find: { q: p.q, fields: { title: 3, body: 1 }, html: true } })   // weighted, prefixes, typos, meaning
shape: p => ({ similar: { to: { id: Number(p.id) }, text: ['title', 'body'] } })
shape: (_, me) => ({ recommend: { user: me.id, interactions: { table: 'likes', user: 'user_id', item: 'recipe_id' }, text: ['title'], date: 'created_at' } })
```

`recommend` scores each row by closeness to what the reader liked, what readers
with the same likes liked, popularity and recency, and leaves out what they
already saw.

→ [docs/search.md](https://unpkg.com/browse/@athenox/talos@latest/docs/search.md)

## 8. Databases

| | |
|---|---|
| none | Talos's own SQLite file (`.talos/data.db`, `TALOS_DB`), tables and columns made from the schema at start, never dropped; `seed` fills empty tables; `await app.ready` |
| SQLite | `store: sqliteStore(new DatabaseSync('app.db'))` (Node's or better-sqlite3) |
| MySQL / MariaDB | `store: mysqlStore(mysql.createPool(url))` |
| Drizzle | `schema: { tables: fromDrizzle(drizzleSchema) }`, `syncSchema(store, drizzleSchema)` to create what is missing |

Also: `parseCsv`, `toCsv`, `importCsv` (header aliases, deduplication, a report),
`connector` (an external JSON API into your fields), `feed('rss' | 'sitemap' | 'sitemap-news')`,
`rateLimit`.

## 9. Telemetry, ids and feedback

```ts
app.instanceId                 // this instance — always Talos's own
result.callId                  // every model call (also plan.callId and the x-talos-call header)
await app.feedback(callId, { rating: 4, text: 'right rows, wrong order' })   // a text, a rating 1–5, or both
```

Each instance reports to `stats.talos.korollr.com`, so that someone who comes
with an instance id or a call id can be helped:

| part | |
|---|---|
| `errors` | exceptions in the 5 s after an answer, tied to the call (a crash is sent by the next start) |
| `operations` | each plan and answer — operation, table, columns, status, counts; values only when declared |
| `prompts` | the app's prompt, route templates, free-text requests **anonymised** (unknown words → `<w>`, numbers → `#`) |
| `performance` | latency per call; every minute, Talos's and the process's load |
| `config` | Talos and model versions, Node, platform |
| `schema` | tables and columns — never a row |

Every option, with its default — set one to `false` to stop sending it:

```ts
telemetry: {
  errors: true,          // exceptions right after an answer, tied to the call
  operations: true,      // plans and answers: operation, table, columns, status (values masked)
  prompts: true,         // the app's prompt, route templates, anonymised requests
  performance: true,     // latency per call, load every minute
  config: true,          // Talos/model versions, Node, platform — once
  schema: true,          // tables and columns — once, never a row
  errorWindowMs: 5000,   // how long after an answer an error still counts
  loadEveryMs: 60000,    // load sampling period
}
telemetry: { prompts: false }   // everything but what users typed and the prompt
telemetry: false                // nothing
```

What each one sends exactly, with an example event and what you lose without
it: [docs/telemetry.md](https://unpkg.com/browse/@athenox/talos@latest/docs/telemetry.md).

`TALOS_TELEMETRY=0` or `DO_NOT_TRACK=1` turn it off for the whole process. An
app chooses what is sent, not where.

→ [docs/telemetry.md](https://unpkg.com/browse/@athenox/talos@latest/docs/telemetry.md)

# Results

Measured on 164 sentences recorded from three real applications ported to
Talos (a newspaper, an association site, a plugin store):

| | right plans |
|---|---|
| routes, as used in production | 99.4 % |
| the model alone, no hints | 98.2 % |
| a typo in the sentence | 98.1 % |
| another verb (*remove* for *delete*) | 94.7 % |

A route compiles in about 200 ms on a CPU, once. Ported, those applications'
backends went from 13,152 lines to 6,518, from 1,807 to 661, and from 1,748 to 918.
A complete application built with it — a recipe-sharing site (Nuxt 4,
Tailwind 4: accounts, photos, comments, favourites, recommendations,
search by sentence) — has about 270 lines of backend.

# Environment

| variable | |
|---|---|
| `TALOS_DB` | Talos's own database file (default `.talos/data.db`) |
| `TALOS_THREADS` | cap on the model's CPU threads |
| `TALOS_MODEL_DIR` | load the weights from elsewhere |
| `TALOS_TELEMETRY=0` | no telemetry for the whole process |

# Full documentation

The pages below ship in the package (`node_modules/@athenox/talos/docs/`):

[Getting started](https://unpkg.com/browse/@athenox/talos@latest/docs/getting-started.md) ·
[Schema](https://unpkg.com/browse/@athenox/talos@latest/docs/schema.md) ·
[Routes](https://unpkg.com/browse/@athenox/talos@latest/docs/routes.md) ·
[Accounts](https://unpkg.com/browse/@athenox/talos@latest/docs/accounts.md) ·
[Files](https://unpkg.com/browse/@athenox/talos@latest/docs/files.md) ·
[Search](https://unpkg.com/browse/@athenox/talos@latest/docs/search.md) ·
[Telemetry](https://unpkg.com/browse/@athenox/talos@latest/docs/telemetry.md) ·
[Reference](https://unpkg.com/browse/@athenox/talos@latest/docs/reference.md)

# License

MIT © Athenox Development
