omnigateway 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +89 -608
  2. package/bin/omni.js +6347 -2002
  3. package/gateway.js +7642 -2632
  4. package/package.json +1 -1
  5. package/public/assets/{Chip-shturdCv.js → Chip-Did4Zhvj.js} +1 -1
  6. package/public/assets/{CopyValue-BGt9RRTL.js → CopyValue-Cjg9QXrD.js} +1 -1
  7. package/public/assets/Lamp-C2Q3t5Mn.js +25 -0
  8. package/public/assets/Rack-D0_N-xsd.js +476 -0
  9. package/public/assets/{RequestTable-huSAoNZg.js → RequestTable-i9paP_7e.js} +1 -1
  10. package/public/assets/{SummaryDeck-BPo2bMSC.js → SummaryDeck-g4yLDeXB.js} +1 -1
  11. package/public/assets/{Toggle-C84xs_YB.js → Toggle-D-NDlneh.js} +1 -1
  12. package/public/assets/{TokenBreakdown-CmzXuENj.js → TokenBreakdown-D5ZIngM6.js} +1 -1
  13. package/public/assets/{WindowChart-BZzVrxYk.js → WindowChart-BsonO5Py.js} +1 -1
  14. package/public/assets/{_app.accounts-DMmtkwnl.js → _app.accounts-DFhyE62a.js} +1 -1
  15. package/public/assets/{_app.console-BBz3kCPV.js → _app.console-BfHcfQMp.js} +6 -6
  16. package/public/assets/{_app.database-ZvtMVgsh.js → _app.database-Cgw9z3rK.js} +1 -1
  17. package/public/assets/{_app.index-DKouD-rz.js → _app.index-Blw3S4-3.js} +1 -1
  18. package/public/assets/{_app.keys-BG9-zfh6.js → _app.keys-DrdfnfIk.js} +1 -1
  19. package/public/assets/{_app.logs-DjinYGYS.js → _app.logs-DUOqlJzB.js} +1 -1
  20. package/public/assets/{_app.models-BCfQVuoT.js → _app.models-Dm83HRaM.js} +1 -1
  21. package/public/assets/{_app.plugins._pluginId-Dm48f3wz.js → _app.plugins._pluginId-BmJGDYxw.js} +1 -1
  22. package/public/assets/{_app.settings-C42ylcNP.js → _app.settings-66rm4vHJ.js} +1 -1
  23. package/public/assets/{_app.usage-DfPM6eQ-.js → _app.usage-BCufy5XR.js} +1 -1
  24. package/public/assets/{chevron-right-HI_fK6Gw.js → chevron-right-BZ4QmUDM.js} +1 -1
  25. package/public/assets/{client-TUf0nuim.js → client-De1_cEH5.js} +1 -1
  26. package/public/assets/index-PP8vsvw3.js +13 -0
  27. package/public/assets/{login-w-hVstKf.js → login-CkZlwcdd.js} +1 -1
  28. package/public/assets/plus-D5AwIXj3.js +1 -0
  29. package/public/assets/preload-helper-BC9bKUg1.js +1 -0
  30. package/public/assets/shared-C47BfV_u.js +77 -0
  31. package/public/assets/{trash-2-Bq4GaXzx.js → trash-2-BLc5o7DN.js} +1 -1
  32. package/public/index.html +4 -7
  33. package/public/shared/chunks/{react-dom-BTlfKTOo.js → react-dom-BFxyned6.js} +1 -1
  34. package/public/shared/dashboard-sdk.js +1 -1
  35. package/public/shared/react-dom-client.js +3 -3
  36. package/public/shared/react-dom.js +1 -1
  37. package/public/shared/react-query.js +1 -1
  38. package/public/shared/styled-components.js +2 -2
  39. package/public/assets/Lamp-CVGUT2YK.js +0 -25
  40. package/public/assets/Rack-BbZyyu2u.js +0 -466
  41. package/public/assets/_app.plugins._pluginId-B9k5eqAt.js +0 -2
  42. package/public/assets/index-C66Zn0tX.js +0 -13
  43. package/public/assets/login-DWgivEPK.js +0 -2
  44. package/public/assets/plus-BCf2TPqz.js +0 -1
  45. package/public/assets/preload-helper-Unv7FI91.js +0 -1
  46. package/public/assets/shared-XRnr7At9.js +0 -77
  47. package/public/assets/stream-3PafhwhZ.js +0 -1
package/README.md CHANGED
@@ -8,16 +8,24 @@ subscriptions. Point any compatible client at it, ask for a model you defined,
8
8
  and the gateway picks an account that can serve it — falling back to another
9
9
  when one is rate-limited, expired, or out of quota.
10
10
 
11
- It runs on one machine, stores everything in a local SQLite file, and never
12
- logs the contents of your prompts or replies.
11
+ It runs as one process on a local SQLite file by default, scales out onto
12
+ Postgres and Redis when you need a fleet, and never logs the contents of your
13
+ prompts or replies.
13
14
 
14
15
  ```bash
15
16
  bun install -g omnigateway
16
17
  omni start
17
18
  ```
18
19
 
19
- > Status: in use and complete for its scope. Version 1 targets a single
20
- > machine and a single operator — see [Scope](#scope-and-limits).
20
+ > Status: in use and complete for its scope see [Scope](#scope-and-limits).
21
+
22
+ **Further reading**, once the gateway is up:
23
+
24
+ - [docs/client-api.md](docs/client-api.md) — endpoints, authentication, rate-limit headers, and how tools decide where a request can go
25
+ - [docs/operations.md](docs/operations.md) — key limits, logs, recording bodies, snapshots and restore
26
+ - [docs/deploying.md](docs/deploying.md) — systemd, reverse proxies, cluster mode, Docker and Kubernetes
27
+ - [docs/plugins.md](docs/plugins.md) — installing, verifying and removing plugins
28
+ - [ARCHITECTURE.md](ARCHITECTURE.md) — how it is built
21
29
 
22
30
  ## What it does
23
31
 
@@ -59,85 +67,17 @@ omni start
59
67
 
60
68
  ## How it is built
61
69
 
62
- A Bun workspace monorepo. The layering is deliberate and enforced by what each
63
- package is allowed to import: a provider-neutral core at the bottom, provider
70
+ A Bun workspace monorepo: a provider-neutral core at the bottom, provider
64
71
  knowledge in one place, pure routing above that, and every side effect pushed up
65
- into the gateway process.
66
-
67
- ```mermaid
68
- graph TD
69
- subgraph frontends[Front ends]
70
- gateway["apps/gateway<br/><i>Elysia server, dispatch, loops</i>"]
71
- cli["apps/cli<br/><i>the omni binary</i>"]
72
- dashboard["apps/dashboard<br/><i>React console</i>"]
73
- end
74
-
75
- control["@omni/control<br/><i>every operator action;<br/>no HTTP, argv or terminal</i>"]
76
- router["@omni/router<br/><i>pure ranking;<br/>no I/O, no timers</i>"]
77
- store["@omni/store<br/><i>SQLite + field encryption</i>"]
78
- providers["@omni/providers<br/><i>adapters, wire codecs,<br/>catalog, HTTP client</i>"]
79
- rtk["@omni/rtk<br/><i>tool-result filters</i>"]
80
- ponytail["@omni/ponytail<br/><i>vendored coding ruleset</i>"]
81
- ratelimit["@omni/ratelimit<br/><i>key limit arithmetic;<br/>no clock, no state</i>"]
82
- ir["@omni/ir<br/><i>domain model — depends on nothing</i>"]
83
-
84
- gateway --> control
85
- gateway --> rtk
86
- gateway --> ponytail
87
- gateway --> ratelimit
88
- cli --> control
89
- dashboard -. "types only" .-> ir
90
- dashboard -. "types only" .-> store
91
- control --> router
92
- control --> ponytail
93
- control --> ratelimit
94
- router -. "types + 2 pure helpers,<br/>via /types subpath" .-> store
95
- router --> providers
96
- store --> rtk
97
- store --> ponytail
98
- store --> ratelimit
99
- providers --> ir
100
- rtk --> ir
101
- ponytail --> ir
102
- store --> ir
103
- ```
72
+ into the gateway process. The gateway and the CLI are two front ends over the
73
+ same `@omni/control` functions — the CLI opens the SQLite file directly, so it
74
+ works when the gateway is down.
104
75
 
105
- Arrows read *depends on*, and the direction never reverses. Two rules do most of
106
- the work: `@omni/ir` is side-effect free and imports nothing, and `@omni/router`
107
- is a pure function no network, no database, no timers. Everything that has to
108
- touch the world is pushed up into the gateway process.
109
-
110
- `@omni/ratelimit` is the same shape applied to key limits: it decides whether a
111
- request is over a ceiling, and it holds no clock and no counters — `now` is a
112
- parameter and the counts are handed to it, so it never learns whether a number
113
- came from memory or from SQLite. It imports nothing but its schema validator, not
114
- even `@omni/ir`. The rings and the in-flight gauge live in the gateway, because
115
- state is not the package's job, and that split is what lets sliding-window
116
- arithmetic be tested without a gateway, a store, or a clock.
117
-
118
- The dashboard's edges are dotted because they are type-level only. It has no edge
119
- to `@omni/providers` at all: provider names, colours, order and model lists reach
120
- the console over `GET /api/catalog`, because a provider loaded from
121
- `<root>/plugins/` exists only at runtime and no build-time import could see one.
122
-
123
- `@omni/providers/catalog` and `/descriptors` are still deliberately import-free,
124
- for a different reason than they used to be: the pure router imports
125
- `descriptors`, and the leaf property is what lets it.
126
-
127
- The router's dotted edge into `@omni/store` is nearly the same story: its
128
- package-root imports are all `import type`. Two pure arithmetic helpers,
129
- `durationFor` and `cacheReadRate`, do run at runtime — imported through the
130
- `@omni/store/types` leaf subpath, so neither SQLite nor crypto enters the
131
- router's module graph and the no-I/O rule holds.
132
-
133
- The gateway and the CLI are two front ends over the same `@omni/control`
134
- functions. The CLI does not call the running server's API — it opens the same
135
- SQLite file directly, which is why it still works when the gateway is down.
136
-
137
- **[ARCHITECTURE.md](ARCHITECTURE.md)** goes a level deeper: a request traced end
138
- to end, how routing ranks and excludes accounts, the commit point that decides
139
- whether failover is still possible, the provider adapter shape, the database
140
- schema and its encryption boundary, and the background loops.
76
+ **[ARCHITECTURE.md](ARCHITECTURE.md)** has the package map and layering rules,
77
+ then goes a level deeper: a request traced end to end, how routing ranks and
78
+ excludes accounts, the commit point that decides whether failover is still
79
+ possible, the provider adapter shape, the database schema and its encryption
80
+ boundary, and the background loops.
141
81
 
142
82
  ## Requirements
143
83
 
@@ -218,12 +158,9 @@ omni models catalog # what is available
218
158
  omni models put fast --from-catalog anthropic:claude-sonnet-5
219
159
  ```
220
160
 
221
- Two catalog pricing caveats: xAI doubles its rate at or above 200K context —
222
- the higher rate applies to every token, but a target holds one flat price, so
223
- edit the saved target if you run grok long-context; and Kilo's `kilo-auto/*`
224
- routers carry no published rate, so the router treats them as unpriced rather
225
- than free — set a real `costPerMTok` on the saved target to have one ranked.
226
- Details in [docs/adding-a-provider.md](docs/adding-a-provider.md).
161
+ Two providers price in ways a flat per-target rate cannot express xAI above
162
+ 200K context and Kilo's `kilo-auto/*` routers; the caveats are in
163
+ [docs/adding-a-provider.md](docs/adding-a-provider.md).
227
164
 
228
165
  Mint a key for your client. **It is printed once and stored only as a hash:**
229
166
 
@@ -232,45 +169,10 @@ omni keys create --label laptop
232
169
  ```
233
170
 
234
171
  Bound what the key can do with `--limit <dimension>:<window>=<value>`, repeated
235
- once per pair. An unset pair is unlimited:
236
-
237
- ```bash
238
- omni keys create --label ci --limit requests:1m=60
239
- ```
240
-
241
- Every window is *sliding*, so a key cannot spend two windows' allowance either
242
- side of a clock edge. `requests` and `tokens` take `1m`, `5h`, and `1w`; `spend`
243
- takes `5h` and `1w`; `concurrency` is not a window at all but a ceiling on
244
- requests in flight at once.
245
-
246
- `tokens` and `spend` are debited once a response completes, because an exact
247
- count exists only then — a key at its ceiling is refused on its *next* request.
248
- The `5h` and `1w` counts derive from `request_logs`, so a `1w` limit on an
249
- installation that prunes logs after three days really enforces three days. They
250
- are cached for thirty seconds and so can read slightly high, never low: refused
251
- early rather than let past a ceiling you set. If the database cannot answer they
252
- stop enforcing and the gateway logs it, while `1m` and `concurrency` are held in
253
- memory and go on enforcing exactly.
254
-
255
- > **Breaking:** `--rate-limit N` is removed, not aliased. Use
256
- > `--limit requests:1m=N`. A script still passing the old flag stops with an
257
- > unknown-flag error rather than quietly taking a deprecated path.
258
-
259
- Limits are editable after the key exists, unlike `--no-bodies` below. `omni keys
260
- list` prints a compact summary; the full matrix and what has gone against it
261
- need one key's id:
262
-
263
- ```bash
264
- omni keys limits <id>
265
- omni keys limits <id> --set tokens:1w=50000000
266
- omni keys limits <id> --unset spend:5h
267
- ```
268
-
269
- `--unset` names a pair that is actually set, so a typo fails rather than
270
- reporting a change it did not make. The usage shown counts completed requests
271
- only, so it reads at or below what the gateway is enforcing, and `concurrency`
272
- shows no figure — that gauge lives in the process, not the database. The
273
- console's Keys screen shows and edits the same matrix.
172
+ once per pair `omni keys create --label ci --limit requests:1m=60`. An unset
173
+ pair is unlimited. Windows slide, `tokens` and `spend` are debited on
174
+ completion, and limits are editable afterwards with `omni keys limits`; the
175
+ semantics are in [docs/operations.md](docs/operations.md#key-limits).
274
176
 
275
177
  Now use it:
276
178
 
@@ -303,56 +205,13 @@ x-api-key: <gateway-key>
303
205
  ```
304
206
 
305
207
  Ask for one of your virtual models by name. A bare provider model
306
- (`claude-sonnet-5`, `gpt-5`) also works if an account can serve it.
307
-
308
- `GET /v1/models` answers both client families from one listing: each entry
309
- carries the OpenAI keys (`object`, `created`, `owned_by`) and the Anthropic ones
310
- (`type`, `display_name`, `created_at`, `max_input_tokens`, `max_tokens`) at
311
- once.
312
-
313
- Most tools that accept a custom base URL work unchanged: set it to
314
- `http://127.0.0.1:9000` and use a gateway key where the provider key goes.
315
-
316
- ### Rate-limit headers
317
-
318
- Every response carries the limit headers of the surface you asked on, so an SDK
319
- backs off using the code it already ships — the Anthropic dialect on
320
- `/v1/messages`, the OpenAI one on `/v1/chat/completions` and `/v1/responses`:
321
-
322
- ```http
323
- anthropic-ratelimit-requests-limit: 2000 x-ratelimit-limit-requests: 2000
324
- anthropic-ratelimit-requests-remaining: 1841 x-ratelimit-remaining-requests: 1841
325
- anthropic-ratelimit-requests-reset: 2026-08-19T14:32:07Z x-ratelimit-reset-requests: 4h51m22s
326
- ```
327
-
328
- `requests-remaining` counts the request being answered; `tokens-remaining` does
329
- not and cannot — the response is still being written when the header goes out.
330
- Where a key has several windows on one dimension, the headers report the one
331
- **nearest exhaustion**, not the reassuring ones. `spend` and `concurrency`
332
- render on neither dialect, because no vendor defines a header for them. A
333
- refusal is `429` with `Retry-After` in seconds, computed from the oldest request
334
- still inside the window that refused you.
208
+ (`claude-sonnet-5`, `gpt-5`) also works if an account can serve it. Most tools
209
+ that accept a custom base URL work unchanged: set it to `http://127.0.0.1:9000`
210
+ and use a gateway key where the provider key goes.
335
211
 
336
- ### Tools and routing
337
-
338
- Two kinds of tool behave differently, and the difference decides where a
339
- request can go.
340
-
341
- A **custom tool** — a name, a description, and a JSON Schema — is portable. It
342
- translates to every provider, so a request using one routes across your whole
343
- pool as usual.
344
-
345
- An **Anthropic-defined tool** — web search, web fetch, code execution, Bash,
346
- text editor, computer use, memory, tool search, advisor, or an MCP toolset —
347
- has a schema Anthropic owns. No other provider can express it, so any request
348
- declaring one, *or replaying the blocks a previous one produced*, is routed to
349
- an Anthropic account only. If the virtual model you asked for has no Anthropic
350
- target, the request fails at routing with the unsupported requirement named,
351
- rather than quietly losing the tool.
352
-
353
- The gateway forwards the tool version you send and never upgrades it. Betas
354
- stay yours: send `anthropic-beta` yourself, as the tool requires — the gateway
355
- carries the header through but does not add one on your behalf.
212
+ Rate-limit headers, the `/v1/models` listing shape, and why an Anthropic-defined
213
+ tool pins a request to an Anthropic account are in
214
+ [docs/client-api.md](docs/client-api.md).
356
215
 
357
216
  ## The CLI
358
217
 
@@ -398,78 +257,13 @@ each candidate's score, and every account that was excluded with the reason.
398
257
  `omni status` is the "is anything wrong" command: process state, per-account
399
258
  health, and how much provider quota each account has left.
400
259
 
401
- ## Running it as a service
260
+ ## Running it somewhere other than your laptop
402
261
 
403
- On a machine with systemd:
404
-
405
- ```bash
406
- omni service install --enable # writes a user unit for this installation
407
- omni start # from here on, start/stop delegate to systemctl
408
- omni console # reads the journal (or the log file, without systemd)
409
- ```
410
-
411
- Use `--system` for a system-wide unit (needs root). Without systemd, `omni
412
- start` supervises the process itself with a pidfile under
413
- `~/.local/state/omnigateway`. Either way, `omni start` returns only once
414
- `/health` actually answers.
415
-
416
- ### Restarting and stopping from the console
417
-
418
- The console can restart and shut down the gateway from the foot of its sidebar.
419
- Restart works under systemd — the gateway asks the manager rather than
420
- signalling itself, because a handled SIGTERM exits cleanly, which systemd's
421
- `Restart=on-failure` reads as success — reports uncertainty in a container,
422
- whose restart policy cannot be read from inside, and disables itself with no
423
- supervisor; use `omni restart` from a terminal there. Shutdown is offered in
424
- every shape. In a container it is a one-way door: bring the process back from
425
- the host.
426
-
427
- ### Behind a reverse proxy
428
-
429
- Set `OMNI_BASE_URL` to the public HTTPS origin, or OAuth callbacks come back to
430
- the wrong host.
431
-
432
- Beyond that, two things travel badly through a proxy, and both are streams.
433
- Client responses on `/v1/*` are server-sent events, and the console keeps one
434
- WebSocket open on `/api/stream`. Neither is optional: buffer the first and every
435
- token of an agent's reply arrives at once at the end, and drop the second and
436
- the console silently falls back to polling.
437
-
438
- Caddy and Cloudflare pass WebSockets and unbuffered responses by default and
439
- need nothing. nginx needs telling:
440
-
441
- ```nginx
442
- location / {
443
- proxy_pass http://127.0.0.1:9000;
444
- proxy_http_version 1.1;
445
-
446
- # Without these two the Upgrade handshake never reaches the gateway and the
447
- # console shows LIVE·POLL instead of LIVE·PUSH. It keeps working — the
448
- # fallback exists for exactly this — but you paid for a socket you are not
449
- # getting.
450
- proxy_set_header Upgrade $http_upgrade;
451
- proxy_set_header Connection "upgrade";
452
-
453
- proxy_set_header Host $host;
454
- proxy_set_header X-Forwarded-Proto $scheme;
455
-
456
- # The socket's heartbeat is 20s and it gives up on a missed pong at 60s.
457
- # A read timeout below that closes a healthy connection from the outside,
458
- # and the console reconnects in a loop that looks like an unstable gateway.
459
- proxy_read_timeout 300s;
460
-
461
- # SSE must not be buffered. The gateway already sends
462
- # `x-accel-buffering: no` on streaming responses, which nginx honours on its
463
- # own, so this line is belt and braces for a proxy chain where something
464
- # else strips that header before nginx sees it.
465
- proxy_buffering off;
466
- }
467
- ```
468
-
469
- The gateway sends downstream `: keepalive` comments on streaming responses
470
- because provider heartbeats are decoded away, so an idle stream still looks
471
- alive to whatever sits in between. Keep any idle timeout in the proxy above
472
- your longest expected request.
262
+ `omni service install --enable` writes a systemd unit; behind a reverse proxy,
263
+ set `OMNI_BASE_URL` and pass WebSocket upgrades and unbuffered SSE through.
264
+ Several replicas behind a load balancer are **cluster mode** on Postgres and
265
+ Redis. All of it, with the nginx block and the Kubernetes base, is in
266
+ [docs/deploying.md](docs/deploying.md).
473
267
 
474
268
  ## Configuration
475
269
 
@@ -485,10 +279,15 @@ Configuration is environment variables, read from the installation's `.env`:
485
279
  | `OMNI_STATIC_DIR` | No | the console shipped with the server | Serve a different console build |
486
280
  | `OMNI_LOG_LEVEL` | No | `info` | Stdout threshold: `debug`, `info`, `warn`, or `error` |
487
281
  | `OMNI_LOG_FILE` | No | the systemd journal, when there is one | Where stdout was already redirected, so the Console screen can read it back. Names a file; does not create one |
488
- | `OMNI_BODY_LOGGING_ALLOWED` | No | unset | Permits request/response body capture on this installation. Read at boot. Capture also needs the runtime setting; see [Recording bodies](#recording-bodies) |
282
+ | `OMNI_BODY_LOGGING_ALLOWED` | No | unset | Permits request/response body capture on this installation. Read at boot. Capture also needs the runtime setting; see [Recording bodies](docs/operations.md#recording-bodies) |
283
+ | `OMNI_METRICS_TOKEN` | No | unset | Registers authenticated `GET /metrics`; unset means no route |
284
+ | `OMNI_METRICS_MAX_SERIES` | No | `5000` | Series cap before new `api_key_id` values fold to `other` |
285
+ | `OMNI_OTLP_ENDPOINT` | No | unset | Enables OTLP/HTTP span export to `<endpoint>/v1/traces` |
286
+ | `OMNI_OTLP_HEADERS` | No | unset | Comma-separated `k=v` headers sent to the collector |
287
+ | `OMNI_TRACE_SAMPLE` | No | `1.0` | Head sampling ratio from `0` to `1`; inbound sampled trace context is honoured |
489
288
  | `OMNI_ROOT` | No | the installation in the current directory, else `~/.config/omnigateway` | Which installation the CLI acts on, when `--root` is not passed |
490
289
  | `OMNI_PLUGIN_REGISTRY` | No | the public npm registry | Registry `omni plugin install <name>` resolves through; must be `https://` |
491
- | `OMNI_CLUSTER_MODE` | No | unset | `true` selects [cluster mode](#running-more-than-one-gateway) and requires the two URLs below; unset is one process on SQLite, and then the URLs must be unset too |
290
+ | `OMNI_CLUSTER_MODE` | No | unset | `true` selects [cluster mode](docs/deploying.md#running-more-than-one-gateway) and requires the two URLs below; unset is one process on SQLite, and then the URLs must be unset too |
492
291
  | `OMNI_DATABASE_URL` | In cluster mode | — | The shared Postgres store |
493
292
  | `OMNI_REDIS_URL` | In cluster mode | — | The coordinator every process of a cluster shares: rate-limit counters, sessions, leases, push fan-out |
494
293
 
@@ -503,28 +302,9 @@ version pins — are deliberately left out of this table and documented in `.env
503
302
  change how the gateway identifies itself to a provider, which is not configuration in the sense
504
303
  the rest of this table is.
505
304
 
506
- Gateway events are written to stdout as one greppable line each: process lifecycle, OAuth
507
- refreshes, quota probes, failover, and errors.
508
-
509
- `OMNI_LOG_FILE` *names* where output was captured; it does not redirect it. Setting it alone
510
- leaves the log empty, because the gateway still writes to stdout. Redirect the output and name
511
- the same path:
512
-
513
- ```bash
514
- bun apps/gateway/src/index.ts >> /var/log/omni.log 2>&1
515
- ```
516
-
517
- `omni start` does both for the gateway it supervises, and under systemd the journal needs no
518
- setup.
519
-
520
- In a fleet, capture is per process. Each replica can capture its own — tee its stdout to a file
521
- inside the container and point `OMNI_LOG_FILE` at the same path — and the Console screen then
522
- merges every process's tail and lets you pick one. That is worth having for an incident on a
523
- running pod, and it is not a log stack: the file dies with the container, nothing rotates it, and
524
- the screen reads one process at a time. Ship stdout to a collector for anything beyond that —
525
- Elasticsearch and Kibana, Loki and Grafana, or whatever already reads your containers — where the
526
- lines outlive the process that wrote them and can be searched across all of them at once. The
527
- Console screen says so itself when it finds a fleet capturing nothing.
305
+ Gateway events are written to stdout as one greppable line each. `OMNI_LOG_FILE` *names* where
306
+ that output was captured; it does not redirect it — see
307
+ [docs/operations.md](docs/operations.md#logs).
528
308
 
529
309
  Everything else lives in the database rather than the environment, so it can be
530
310
  changed without a restart: the six routing weights, `maxAttempts`,
@@ -536,192 +316,16 @@ console. `quotaPollIntervalMs` is the one exception: the poller reads it once at
536
316
  boot, so a change to it takes a restart. Snapshot retention —
537
317
  `snapshotKeepLatest` and `snapshotMaxAgeDays` — is stored alongside them but
538
318
  deliberately edited on the Database screen instead; see
539
- [Snapshots and restore](#snapshots-and-restore) for why.
540
-
541
- ## Running more than one gateway
542
-
543
- One process on SQLite is the default and is what every command in this document assumes. A
544
- fleet — several replicas behind a load balancer, on Kubernetes or otherwise — is **cluster
545
- mode**, switched on by `OMNI_CLUSTER_MODE=true` and needing two things beside the gateway:
546
-
547
- - **Postgres** as the store, named by `OMNI_DATABASE_URL`. Every replica reads and writes one
548
- database; there is no SQLite file, no snapshot, no restore, and no `omni db vacuum` — those are
549
- `pg_dump`'s job now, and the Database screen says so.
550
- - **Redis** (or Valkey) as the coordinator, named by `OMNI_REDIS_URL`. It holds what a fleet must
551
- agree on and a database is the wrong shape for: the per-minute request ring and the concurrency
552
- gauge, the long-window counters, admin sessions, pending OAuth flows, quota-probe cooldowns, the
553
- leases that make the background loops run once rather than N times, and the fan-out that lets a
554
- console on one replica hear a change made on another.
555
-
556
- ```bash
557
- OMNI_CLUSTER_MODE=true
558
- OMNI_DATABASE_URL=postgres://omni:secret@db.internal:5432/omni
559
- OMNI_REDIS_URL=redis://cache.internal:6379
560
- OMNI_ENCRYPTION_KEY=… # the same on every replica
561
- ```
562
-
563
- Boot refuses the switch without both URLs, and refuses either URL without the switch: a
564
- replica that believes it is clustered and is not is the failure this variable exists to make
565
- loud.
566
-
567
- What holds across the fleet, exactly: every API-key limit at every window and dimension; token
568
- refresh, which one replica performs while the others wait and reuse the result; a cookie issued
569
- by one replica, which every other verifies and a password change ends everywhere. What is
570
- per-replica and says so: the console's **Console** screen shows one process's stdout, so it grows
571
- a selector when there is more than one, and its default view is every process merged by time.
572
- What is per-replica and does not say so: the routing `load` weight is one round trip stale
573
- between replicas, so a burst arriving at once on two of them can stack for that long.
574
-
575
- **No sticky sessions are needed.** The WebSocket the console holds may land on any replica; the
576
- ingress only needs to pass upgrades and hold an idle timeout above ten seconds. A rolling deploy
577
- closes each replica's sockets with `1001`, the console reconnects to a live one and refetches
578
- once.
579
-
580
- **When Redis is unreachable**, the request path keeps serving: each replica falls back to its
581
- own in-memory counters, so limits degrade to N-fold until Redis returns, and one line per thirty
582
- seconds says so (`coord=redis coordFallback=true`). The console does not: a session that cannot
583
- be checked against the shared store is refused with `503`, because a session verified locally is
584
- one a password change on another replica cannot end. `GET /health` reports `mode`, `nodeId` and
585
- `coord` (`ok` or `fallback`) for a readiness probe to read.
586
-
587
- Plugins are loaded from each replica's own `<root>/plugins/`; bake them into the image so every
588
- replica holds the same set. Plugin storage is Postgres in cluster mode, so a plugin's SQL is
589
- written for it. `POST /api/restart` refuses in cluster mode — roll the deployment instead.
590
-
591
- Moving an existing SQLite installation onto Postgres:
592
-
593
- ```bash
594
- omni stop
595
- omni db migrate --to postgres://omni:secret@db.internal:5432/omni
596
- ```
597
-
598
- It copies credentials (re-encrypted with the same `OMNI_ENCRYPTION_KEY`), API keys, virtual
599
- models, settings, both passwords and every completed request log into an **empty** Postgres
600
- database, rebuilds the rollups, and prints what it did not carry: request bodies, `usage_daily`
601
- older than the retained logs, quota readings and breaker state (re-measured within a poll
602
- interval), sessions, and `plugin_*` tables, whose SQL is the source dialect's. It refuses while a
603
- gateway is running and refuses a target that holds anything.
604
-
605
- ## Recording bodies
606
-
607
- By default the gateway records no prompts and no responses. For incident
608
- forensics, capture is opt-in and needs **two independent keys, both required**:
609
- `OMNI_BODY_LOGGING_ALLOWED=1` read at boot, plus the **Capture request and
610
- response bodies** setting (console Settings, or `omni settings set
611
- bodyLoggingEnabled true`). An admin session alone cannot start recording your
612
- users' prompts; with the variable unset the console says the switch does
613
- nothing rather than letting you flip it. Capture can be toggled mid-incident;
614
- turning it off stops new capture and does not delete what was written.
615
-
616
- A gateway key created with `--no-bodies` is never captured whatever the setting
617
- says — made at issue time, not reversible afterwards; reissue instead. Raw SSE
618
- frames are captured separately, under `bodyLoggingCaptureStreamChunks`, and are
619
- far the larger store.
620
-
621
- What is captured: what arrived at `/v1/*` and what was returned, plus every
622
- provider attempt in dispatch order — the client side pre-RTK, attempts post-RTK,
623
- labelled as such in console and CLI. Headers are never captured, at any layer.
624
- Read them from the console's Logs screen or:
319
+ [Snapshots and restore](docs/operations.md#snapshots-and-restore) for why.
625
320
 
626
- ```bash
627
- omni bodies req_550e8400-… # the frame: state, size, one line per attempt
628
- omni bodies req_550e8400-… --full # the payloads themselves
629
- omni bodies req_550e8400-… --json # the artifact, for a script
630
- ```
321
+ ## Recording bodies, snapshots, and the database
631
322
 
632
- The bare command prints only the frame, never conversations asking costs one
633
- flag. A missing artifact answers rather than errors: `not captured`, `captured,
634
- then lost` (retention or the row cap), or `captured, but unreadable` (usually a
635
- changed `OMNI_ENCRYPTION_KEY`). There is no command to delete a captured body;
636
- a second path that erases forensic evidence on request loses incident records.
637
-
638
- Artifacts live at `request_bodies/YYYY/MM/DD/<requestId>.json.enc` beside the
639
- database, AES-256-GCM under `OMNI_ENCRYPTION_KEY`; changing that key invalidates
640
- every artifact. Bounds: log-retention expiry plus a hard **100,000-row cap**, so
641
- capture is forensics, not an archive — size a volume against roughly 100 GB
642
- worst case, though most artifacts are kilobytes.
643
-
644
- Masking is best-effort — bearer tokens, vendor-prefixed keys, long opaque
645
- tokens are elided before write — a reduction in exposure, not a guarantee, and
646
- it costs fidelity. Treat the tree as you would the prompts themselves: encrypted
647
- at rest, on a volume you control, never pasted into a ticket.
648
- [ARCHITECTURE.md](ARCHITECTURE.md#body-capture-forensics) documents the storage
649
- format, structural bounds, and masking rules.
650
-
651
- ## Snapshots and restore
652
-
653
- The console's Database screen reports what this installation occupies — the
654
- database file, its write-ahead log, the captured-body tree, the free pages a
655
- compaction would give back, and every table by size — and takes snapshots.
656
- `omni db stats` prints the same figures. On Postgres both show the server's own
657
- size, the `request_bodies` table and the per-table listing instead; there is no
658
- file, so nothing here compacts, snapshots or restores it — `pg_dump` is the
659
- backup.
660
-
661
- **What a snapshot is.** One self-contained SQLite file, written into a
662
- `snapshots/` directory beside the database. The write-ahead log is folded in, so
663
- there is nothing else to copy alongside it, and taking one is safe while the
664
- gateway is running: it reads through SQLite rather than copying bytes off disk.
665
-
666
- **What it is not.** The sibling `request_bodies/` tree is excluded, always. A
667
- snapshot is never a prompt corpus, and its size tracks your configuration and
668
- usage history rather than your traffic. The cost is that a restore leaves the
669
- captured-body tree out of step with the table: files the restored database has no
670
- row for are collected by the hourly sweep, and a row whose file is gone reads back
671
- as `captured, then lost`.
672
-
673
- **A snapshot does carry secrets** — encrypted provider credentials and gateway
674
- key hashes — inert only because `OMNI_ENCRYPTION_KEY` is not in the file.
675
- Anyone holding both the file and the key holds your provider accounts; treat a
676
- downloaded snapshot as the database itself.
677
-
678
- **Retention** bounds the directory: at most `keepLatest` snapshots are kept, and
679
- nothing older than `maxAgeDays` — 5 and 30 by default. Both bounds have to pass,
680
- so an old snapshot goes even while the count is under the limit, and the newest is
681
- always kept whatever the numbers say. Pruning runs when a snapshot is taken rather
682
- than on a timer, so a quiet installation keeps what it already has. **Edit the
683
- policy on the Database screen**, not on Settings: it is deliberately not part of
684
- the settings form, so a settings save from a client that has never heard of
685
- retention leaves your policy alone instead of resetting it.
686
-
687
- The copy taken automatically on the way into a restore is exempt from retention.
688
- It is the undo.
689
-
690
- **Restoring from the console** happens inside the running gateway. Client traffic
691
- on `/v1/*` is refused with a retryable 503 while the file is replaced; `/api/*`
692
- and `/health` keep answering. The screen also uploads a database file from
693
- elsewhere, up to 2 GiB — bring `OMNI_ENCRYPTION_KEY` with it, or the credentials
694
- in it are unreadable. The file is integrity-checked before anything is touched,
695
- and a copy of what was there is taken first. A restore ends by rebuilding the
696
- usage rollup, which briefly blocks even `/api/*`: roughly 0.4 s per 500k
697
- request-log rows, 1.6 s at 2M, 6.5 s at 8M. A failure is logged rather than
698
- raised — the database is live either way, and `omni doctor` reports a rollup
699
- that disagrees with its rows.
700
-
701
- Before it asks, `restore` prints one row per table with what the snapshot holds
702
- against what is live, so the confirmation is informed rather than a judgement on
703
- an id and an mtime. `--dry-run` prints the same table and exits without asking.
704
- The counts cover the tables the integrity check reads, so they are a floor on
705
- what a restore replaces rather than the whole of it.
706
-
707
- **`omni db restore <id>` refuses while a gateway is running** against that
708
- installation, and there is no override flag. A second process can open its own
709
- handle but cannot quiesce the gateway's, and moving the file out from under a live
710
- SQLite connection corrupts the database you were trying to rescue. Run `omni stop`
711
- first, or restore from the console, which swaps the file behind its own quiesce
712
- latch.
713
-
714
- **Compaction.** `omni db vacuum`, or the console's equivalent, rewrites the
715
- database and reclaims the pages deletion left free. It holds SQLite's write lock
716
- for the rewrite, so a busy gateway stalls on its writes until it finishes — but
717
- nothing is lost by running it live, and it reports what it actually gave back to
718
- the filesystem.
719
-
720
- **Clearing bodies.** `omni db clear-bodies`, or the Database screen's *Clear
721
- bodies*, deletes every captured prompt and completion on either engine — the
722
- files beside a SQLite database, the `request_bodies` rows on Postgres. The
723
- requests stay in the log with their bodies marked pruned. There is no undo:
724
- bodies are never in a snapshot.
323
+ By default the gateway records no prompts and no responses; capture is opt-in
324
+ and needs both `OMNI_BODY_LOGGING_ALLOWED=1` at boot and a runtime setting, and
325
+ a key created with `--no-bodies` is never captured. Snapshots are one SQLite
326
+ file each, taken live, never carrying captured bodies, and restorable without
327
+ stopping the gateway. Both, with compaction and clearing bodies, are in
328
+ [docs/operations.md](docs/operations.md).
725
329
 
726
330
  ## Docker
727
331
 
@@ -735,62 +339,45 @@ docker run --rm \
735
339
  ```
736
340
 
737
341
  The container listens on `0.0.0.0:9000`, serves the console, and keeps its
738
- database and plugins under `/data`. It runs as the unprivileged `bun` user and
739
- carries a `HEALTHCHECK` on `/health`.
740
-
741
- For a fleet, set `OMNI_CLUSTER_MODE=true` with `OMNI_DATABASE_URL` and `OMNI_REDIS_URL` and
742
- drop the volume;
743
- see [Running more than one gateway](#running-more-than-one-gateway). A
744
- Kubernetes deployment — Deployment, Service, Ingress with the timeouts streaming
745
- needs, HPA, and an example Secret — is under `k8s/` as a kustomize
746
- base:
747
-
748
- ```bash
749
- cp k8s/secret.example.yaml k8s/secret.yaml # edit it
750
- kubectl apply -f k8s/secret.yaml
751
- kubectl apply -k k8s
752
- ```
753
-
754
- Releases deploy by GitOps: a `v*` tag publishes `ghcr.io/harismawan/omnigateway:<version>`
755
- and the workflow commits that version into `k8s/kustomization.yaml` on `main`, which Argo
756
- CD syncs. Rolling back is editing `newTag` by hand.
757
-
758
- Plugins in a fleet are baked into the image so every replica holds the same
759
- set: `COPY plugins/ /data/plugins/` in a derived Dockerfile. The image is
760
- public, so no pull secret is configured; a private fork adds
761
- `imagePullSecrets` to the Deployment.
762
-
763
- Give the container a restart policy — `--restart unless-stopped` — if you want a
764
- restart request to bring it back. A container cannot read its own policy, so
765
- without one an exit is simply the end of the installation until you start it
766
- again from the host.
342
+ database and plugins under `/data`. Fleets, the Kubernetes base under `k8s/`,
343
+ GitOps releases and restart policy are in
344
+ [docs/deploying.md](docs/deploying.md#docker).
767
345
 
768
346
  ## Scope and limits
769
347
 
770
348
  Worth knowing before you deploy it:
771
349
 
772
- - **One machine, one operator.** No multi-tenancy, no clustering, no shared
773
- state. The `1m` window and the `concurrency` gauge are counted in the gateway
774
- process and reset when it restarts; `5h` and `1w` are counted from the database
775
- and survive one. Two gateways over one database would not see each other's
776
- short-window counts.
350
+ - **One process on SQLite by default; a fleet is opt-in.** Cluster mode on
351
+ Postgres and Redis shares every limit, session and lease across replicas.
352
+ Without it, the `1m` window and the `concurrency` gauge are counted in the
353
+ gateway process and reset when it restarts; `5h` and `1w` are counted from
354
+ the database and survive one. Two gateways over one SQLite file are not a
355
+ cluster and would not see each other's short-window counts.
356
+ - **One operator, with two narrower views.** An optional read-only console
357
+ password and a per-key client dashboard exist; there is no multi-tenancy —
358
+ every provider account is the operator's, whoever is looking.
777
359
  - **Two grains of usage history.** Detailed request logs are pruned after 30
778
360
  days by default; a daily rollup is kept for 400 days. A day is your host's
779
361
  local midnight, fixed when the row is written.
780
362
  - **Body capture is forensics, not an archive.** It is off unless you turn it on
781
363
  with both keys, it expires on the request-log window, and it is capped at
782
- 100,000 rows. It is not a searchable prompt history and there is no CLI for it.
364
+ 100,000 rows. `omni bodies` reads one request's capture; nothing searches
365
+ across them.
783
366
  - **Snapshots are manual, and local.** Nothing takes one on a schedule and there
784
367
  is no off-host target; retention bounds what you have taken, and a restore
785
368
  always takes one first. Copy them somewhere else yourself if the disk failing
786
- is what you are guarding against.
369
+ is what you are guarding against. On Postgres there are none; `pg_dump` is
370
+ the backup.
787
371
  - **Quota readings come from the providers**, and their usage endpoints are
788
372
  undocumented. An account with nothing reported is treated as unknown, never
789
373
  as unlimited.
790
374
  - **The gateway does not know which model accepts which request shape.** An
791
375
  unsupported combination surfaces as the provider's own 400 rather than being
792
376
  caught earlier.
793
- - Not in scope for version 1: semantic caching, billing, horizontal scaling.
377
+ - Not in scope: semantic caching, billing.
378
+
379
+ What is missing on purpose, what is missing for now, and what is designed but not
380
+ built: [docs/roadmap.md](docs/roadmap.md).
794
381
 
795
382
  ## Security
796
383
 
@@ -799,10 +386,10 @@ Worth knowing before you deploy it:
799
386
  - Prompts and responses are never logged. Request logs hold metadata and token
800
387
  counts only, and no body ever reaches stdout, the journal, or the Console
801
388
  screen. Bodies are stored only if you opt in to
802
- [body capture](#recording-bodies), which needs both an environment variable
389
+ [body capture](docs/operations.md#recording-bodies), which needs both an environment variable
803
390
  and a setting, encrypts what it writes, and can be refused per key.
804
391
  - Gateway keys are stored as hashes. A lost key is reissued, not recovered.
805
- - A [snapshot](#snapshots-and-restore) carries whatever the database does —
392
+ - A [snapshot](docs/operations.md#snapshots-and-restore) carries whatever the database does —
806
393
  encrypted provider credentials, gateway key hashes — and is inert only because
807
394
  `OMNI_ENCRYPTION_KEY` is not in it. Captured bodies are excluded, so a snapshot
808
395
  is never a prompt corpus.
@@ -810,10 +397,10 @@ Worth knowing before you deploy it:
810
397
  reads stdin — so they stay out of your shell history and the process table.
811
398
  - Behind a reverse proxy, set `OMNI_BASE_URL` to the public HTTPS origin so
812
399
  OAuth callbacks match what the providers have registered.
813
- - The gateway talks to your providers and to nobody else. No telemetry, no CDN
814
- fonts, no third-party origins. A plugin may declare outbound origins of its
815
- own, and `omni plugin verify <id>` shows exactly which ones it asked for — as
816
- does its manifest, which is a plain file you can read before installing.
400
+ - The gateway talks to your providers and to nobody else except your collector, if you name
401
+ one with `OMNI_OTLP_ENDPOINT`. No CDN fonts or undeclared third-party origins. A plugin may
402
+ declare outbound origins of its own, and `omni plugin verify <id>` shows exactly which ones it
403
+ asked for — as does its manifest, which is a plain file you can read before installing.
817
404
  - **Plugins run inside the gateway process, with its privileges** — the
818
405
  capability context is a guardrail, not a sandbox. Read the
819
406
  [security note](#plugins) before installing one you did not write.
@@ -834,80 +421,15 @@ omni plugin update some-plugin # reinstall from whatever it was installed
834
421
  omni restart # plugins load at boot, so this is required
835
422
  ```
836
423
 
837
- `update` needs no spec: `install` records the one you typed in
838
- `.omni-install.json` beside the plugin, so picking up a patch release is the
839
- plugin's id and nothing else. A bare package name re-resolves to the current
840
- release; `name@1.2.3` reinstalls that version exactly. A plugin copied in by
841
- hand has no record and `update` says so rather than guessing.
842
-
843
424
  **Nothing in the package is executed by any of these.** There is no dependency
844
425
  resolution, no `node_modules`, and no lifecycle script — the installer fetches,
845
426
  checks, and unpacks, and the plugin's own code is first imported at the next boot.
427
+ How a spec is resolved, what installing by name refuses, what `list` and
428
+ `verify` report, and what `remove` keeps are in
429
+ [docs/plugins.md](docs/plugins.md).
846
430
 
847
- A spec is resolved filesystem-first: directory, then local tarball, then URL,
848
- then registry. That order is the safe one. The reverse would let a published
849
- package shadow the directory you are standing in and turn `omni plugin install
850
- some-plugin` into a download nobody asked for.
851
-
852
- Installing by name refuses more than it accepts, and each refusal happens before
853
- any bytes are fetched: the tarball must be served from the registry's own host,
854
- the registry must advertise an integrity hash or a shasum, and only an exact
855
- version or the registry's `latest` resolves — no ranges, no other dist-tags. Use
856
- `--registry` (or `OMNI_PLUGIN_REGISTRY`) for a private registry; it must be
857
- `https://`.
858
-
859
- A URL you type is different, and the difference is the point: nothing downstream
860
- has a digest to check it against, so TLS to the host you named is the only
861
- assurance there is. That is why `http://` is refused rather than upgraded.
862
-
863
- `omni plugin list` prints what this installation has — id, name, version, the
864
- plugin API and console SDK it was built against, the capabilities it declared,
865
- and whether the gateway would load it:
866
-
867
- ```
868
- ID NAME VERSION API SDK CAPABILITIES STATE
869
- pokemon Pokémon Companion 1.0.0 2 ^1.0.0 storage,files,net:outbound,… ok
870
- ```
871
-
872
- The API column is the plugin API generation, and it is matched **exactly** — a
873
- plugin built against an older one is listed with that as its reason rather than
874
- loaded.
875
-
876
- The capabilities a manifest may declare are `storage`, `files`, `net:outbound`,
877
- `events:request`, `events:limit`, `channels` and `provider`. `channels` is
878
- namespaced topics on the gateway's push socket, which a plugin owns without ever
879
- touching a connection. `provider` lets a plugin supply a provider of its own —
880
- the models, the wire format, and optionally the OAuth flow, so
881
- `omni connect <plugin-id>` works for it exactly as it does for a built-in.
882
- Anything a plugin did not declare is absent from what it is handed.
883
-
884
- `net:outbound` and `provider` each require the manifest to declare `origins`,
885
- and both are enforced: a plugin's own `fetch` and the requests the gateway makes
886
- on a provider plugin's behalf are both refused outside them. That is what makes
887
- `omni plugin verify <id>` worth reading before you install one — where your
888
- prompts can be sent is in the manifest, not only in the code.
889
-
890
- A plugin that would *not* load is listed with the reason rather than hidden,
891
- because a plugin missing from the console is exactly what you are trying to
892
- explain. For one plugin's full detail — its entry points and the outbound
893
- origins it declared — use `omni plugin verify <id>`.
894
-
895
- ### Available plugins
896
-
897
- There is no curated directory to browse, and there is no plan for one. A plugin
898
- is a directory, a tarball, a URL or a package name you point `omni plugin
899
- install` at, and you are expected to know where it came from — see the
900
- [security note](#security) for why that is the model rather than an omission.
901
-
902
- Resolving a name through npm makes distribution easier; it does not make an
903
- unknown plugin safer. Integrity checking proves you received the bytes the
904
- registry advertised, and nothing about who wrote them or what they do once the
905
- gateway imports them.
906
-
907
- None ship in this repository, deliberately. The first one did, and moving it out
908
- is what proved the plugin API actually works from outside: while it built as a
909
- workspace sibling it could reach internal packages no published plugin can, and
910
- two bugs hid in exactly that gap.
431
+ There is no curated directory to browse. None ship in this repository; one is
432
+ published:
911
433
 
912
434
  | Plugin | What it does |
913
435
  | --- | --- |
@@ -925,47 +447,6 @@ pokemon` prints back. Those assets are Nintendo and Game Freak intellectual
925
447
  property, fetched at runtime and never vendored — into that repository, this one,
926
448
  or anything either publishes.
927
449
 
928
- `verify` is the one to run before restarting a gateway that people are using: it
929
- reaches the same verdict the next boot will, from the same code, without loading
930
- the plugin.
931
-
932
- ### Installing on a machine with no checkout
933
-
934
- A published plugin installs by name — no checkout, no build toolchain:
935
-
936
- ```bash
937
- omni plugin install omnigateway-plugin-example
938
- omni plugin verify example && omni restart
939
- ```
940
-
941
- Building and shipping your own plugin — tarball layout, the manifest-at-root
942
- rule, why plaintext `http://` stays refused, Docker mounting — is covered in
943
- [docs/writing-a-plugin.md](docs/writing-a-plugin.md).
944
-
945
- **In Docker**, mount the plugin at `<root>/plugins/<id>` on a volume — the same
946
- layout `install` writes — and restart the container; read-write, not `:ro`,
947
- because a plugin declaring `files` writes its cache inside its own directory.
948
- See [docs/writing-a-plugin.md](docs/writing-a-plugin.md).
949
-
950
- Removing one keeps its data:
951
-
952
- ```bash
953
- omni plugin remove some-plugin # directory goes, database tables stay
954
- omni plugin remove some-plugin --purge # tables too, after confirming
955
- ```
956
-
957
- That default is deliberate. A plugin directory can be reinstalled from the
958
- package it came from; whatever it accumulated in your database cannot be
959
- reinstalled from anything.
960
-
961
- Note what "directory goes" includes: a plugin's `data/` directory is removed
962
- with it. That directory holds cached files a plugin can rebuild — it is excluded
963
- from [snapshots](#snapshots-and-restore) for that reason, so it has no restore
964
- path and is not meant to need one. Only the database tables are kept, and only
965
- those are what `--purge` additionally drops. For the same reason, restoring a snapshot onto an
966
- installation that no longer has a plugin leaves that plugin's tables in place —
967
- `omni doctor` reports them, and nothing removes them for you.
968
-
969
450
  Read the [security note](#security) on what a plugin can reach before installing
970
451
  one you did not write. To write one, see
971
452
  [docs/writing-a-plugin.md](docs/writing-a-plugin.md).