What a recording keeps: the request as it arrived, the reply as it left, and one row per conversation
By default twinny-server keeps metadata and never content. Switch Recording on and it keeps the conversation and the reply, the code around the cursor and the completion, the embedding inputs and never the vectors. Where the capture happens relative to the secret shield, why a stopped autocomplete is a success, how twelve hex characters turn a dozen agent steps into one row, and what the training export throws away. From src/gateway/recording.
The gateway’s usage records say who asked which model, when, for how long and with what outcome. They never say what was asked. Recording is the switch that changes that, per feature, and it is where the content of requests comes to rest on the gateway. This post reads src/gateway/recording/ and the capture in src/protocol/job.ts to say what a record is, when one is written, and what the export does to it.
Three things have to be true
Recorder.enabled(route) is one line: the route is true in the configuration and licensed() returns true. licensed is a function, not a value, read on every decision, because the licence can lapse while the gateway runs; it returns whether the current licence lists recording among its features. Settings can change at runtime too, since the admin page saves them, but the store cannot: it is a file or a database opened at start.
The third thing is disclosure. The team route builds its policy from the configured rules, the recorder’s active routes and the pooled aliases, and recording is added outside the check for the policy licence feature. So the line a developer sees on the Connect to team screen, “The gateway keeps the content of your chat conversations, autocomplete requests”, is sent whenever recording is on, whether or not the licence allows any other policy.
For each request the server asks enabled(capability) once and passes the answer as a capture flag into the inference job. If it is set, the job parses the body, strips the model field (the alias the developer named; the record keeps the alias separately, and the backend model and provider are filled in once routing has resolved them) and keeps the rest as the request. The reply is assembled from the streamed chunks as they pass: { text } for autocomplete, { content, toolCalls, finishReason } for chat. Embeddings keep the inputs and { count, dimensions }, never the vectors.
Where this sits relative to the secret shield matters. The gateway’s own shield (policy.secretShield) wraps the backend client, so it changes what the backend sees and not what is recorded. The capture is the request as it arrived from the extension. The extension’s shield treats a gateway as off the machine, so with twinny.secretShield at its default of offMachine the credentials in a recorded prompt are already placeholders before they reach the gateway; a developer who has turned their shield off records them as they are.
One case gets special handling. Inline completion stops reading a stream once it has the line it wants, so the backend sees a cancellation while the developer saw a completion. The job already reports that as ok when chunks were handed over; the recorder does the same for an autocomplete record that was cancelled with text in its reply, and marks it ended: "client" so the Recordings page can say so. If the store’s append throws, the failure is logged as recording.failed and the request is served anyway; recording never blocks a reply.
Two stores, one sweep
openRecordingStore picks SQLite when this Node has node:sqlite (22.5 or newer, which the Docker image does) and JSON lines otherwise; recording.store can force either, and asking for sqlite where there is none is an error rather than a silent downgrade. The SQLite store is one file, recordings.sqlite, in WAL mode, created 0600 in a 0700 directory, with indexes on time, key and route. The JSONL store is one file per day, appended a line at a time; a torn last line from a crash is skipped on read.
Both answer the same queries: route, key, outcome, a time range, a substring of the request or reply, and paging newest-first by id. The id is the millisecond timestamp zero-padded to fourteen digits plus four random bytes, so string order is time order and the JSONL store can find a record’s day file from its id alone.
Retention is a sweep, not a check on read. Recorder.start() prunes once and then every hour, deleting records older than retentionDays (default 90, allowed 1 to 3650). The two stores do it at different grains: SQLite deletes rows older than the instant, JSONL deletes whole day files, and only a day that lies entirely before the cutoff.
One row per conversation
Agent mode resends the conversation on every tool step, and every later turn resends it again, so one reply could be a dozen records. 4.3.3 folded them. The fold is twelve hex characters: threadOf takes the key name and the first user message, hashes them with SHA-1 and keeps the first twelve. The system prompt is deliberately left out of the hash, because it can change between turns and the thread should not.
The preview is computed at the same time. For chat it is the last user turn; if that turn carries the tool notes the extension puts ahead of a follow-up, the text after [The user's new message:] is used, so a row shows the question rather than JSON. Because every step of a run asks the same question, the preview is prefixed with after 3 tool results · to tell the rows apart. For autocomplete it is the last 160 characters of the prompt, and for embeddings the inputs joined with ⏎.
Both are stored as columns. The Recordings page groups rows that share a thread into one conversation entry with a step count and a summed duration, wherever the steps fall among other requests. Records kept before 4.3.3 have no thread, so on open the SQLite store backfills them 200 rows at a time; a chat record with no user message gets an empty string so it is not read again.
What the export leaves out
twinny-server recordings export reads the store the configuration names, so the server need not be running, and writes one JSON object per line. --format raw is the record as stored. The default, training, is stricter: toTrainingLine returns nothing for any record whose outcome is not ok, because a partial reply teaches the wrong thing.
Chat becomes {"messages": [...]} with the recorded reply appended as the assistant turn. Tool calls survive in the OpenAI fine-tuning shape, tool_calls on the assistant message and tool_call_id on the results, and the tools the request offered are wrapped as {type: "function", function: ...} alongside. A message with no text and no calls is dropped, and so is a record with no messages or an empty reply.
Autocomplete becomes {"prompt", "suffix", "completion"}. The wire request carries both prompt, the code inside a model’s fill-in-the-middle template, and prefix and suffix, the code as it was, which the extension sends alongside it. The export takes prefix when it is present and falls back to prompt, because training wants the code and not one model’s tokens. Embeddings become {"input": [...]}; there is no reply worth keeping.
Every line also carries model (the backend model, or the alias when routing never resolved), key and at, and the filters are --route, --key and --since (7d, 24h or a date). recordings stats counts by route and key.
Switching it on, what developers are told, and where the files live are at docs.twinny.dev/teams/recording.