AI and embeddings
All calls to Gemini (Google’s AI model) go through one piece of code, the AI service (src/ai). Features never talk to Gemini themselves. Prompts, replies, tool arguments and tool results are never logged or put in error messages. Each answer call logs one [ai] line with only the model, rounds, tool calls, input, cached input and output tokens, and time. Output tokens include the model’s thinking.
A token is a small chunk of text, a few characters long, that the model counts its input and output in. Cached input is input the provider had already seen in an earlier call.
Some calls give the model tools: small functions the bot runs for it, such as searching messages. The model asks for a tool, the bot runs it and returns the result, and this repeats until the model answers (see Tool loop). Thinking level is how much reasoning the model does before it answers.
[ai] model=gemini-3.6-flash rounds=3 tools=search_messages:2,list_messages:1 in=18234 cached=9120 out=612 ms=7431An embedding call logs [ai] embed model=... texts=N batches=N ms=N. An embedding is a list of numbers that captures what a text means, see Embeddings.
What leaves your server
Section titled “What leaves your server”Message text goes to Google’s Gemini API in six cases. If the bot has no key, nothing is sent.
| Case | What is sent |
|---|---|
| Embeddings, when active | Message text, the channel name, the author’s display name and a few earlier messages used as context. Also each search phrasing (up to 200 characters, or the whole question for a lookup) whenever a summary or a question searches by meaning, so it can be compared with the vectors. |
| Summaries | The summary’s name, description and prompt, plus whatever the summary tools return to the model: message text (cut to 300 characters), message, channel and author ids, author and channel names and sent times. If Linear is connected, also ticket identifiers, titles, links, dates, states, assignee names and descriptions (cut to 1,000 characters). Messages come only from channels the reader can see. A personal digest sends the member’s display name and Discord ID and their interests (person IDs, topics, channel IDs and notes) in place of a name, description and prompt, and uses only the message tools. |
| Questions | The question (up to 500 characters) with the asker’s display name and Discord ID, up to 30 preloaded matching messages for a simple lookup, and whatever the search tools return to the model (up to about 200,000 tokens per question): message text (cut to 300 characters) with ids, author and channel names and sent times; and meeting titles, summaries, action items and transcript fragments. Only what the asker can see (for a public answer, only what every reader of the channel can see). See Asking the bot questions. |
| Ticket prompts | One new message at a time, only after it passes the filters and limits: its text (up to 4,000 characters). Then, if Linear search finds similar tickets, the draft title and description and, for up to 5 tickets, the identifier, title and description (cut to 300 characters). When the message is a proposal and its author is offered two or more teams, the message text again with the names and one-line descriptions (cut to 300 characters) of those teams, which can include private teams the author belongs to. When a member submits the form: the title, the description (cut to 1,000 characters) and the names of the labels the team can use. Private Linear teams are never searched for tickets. See Ticket prompts. |
| Meeting transcripts | The text of an uploaded transcript or of a voice meeting (Name: words lines, with display names), up to 400,000 characters, with its file name, in parts of up to 200,000 characters (two at most). The model also classifies each action item as new work or an update to an existing ticket and, for an update, picks a status from a closed list. With a Linear key, for each action item and topic its title and description (cut to 300 characters) and, for up to 5 similar tickets, the identifier, title and description (cut to 300 characters). For an update item, the words that named the ticket are the search term and the verdict prompt says it is an update. When the transcript names no team and there is new work, the meeting title, decisions and action item titles go out once more with the names and one-line descriptions of the public Linear teams, to suggest a team. When someone presses Create selected: the item title, the description (cut to 1,000 characters) and the names of the labels the team can use. See Meeting transcripts. |
| Tag reading | The text of a message that tags the bot, with the mention removed, cut to 2,000 characters. No name, id or channel. Only when reminders are on and the author is allowed, or asking is on, up to 20 times per author per hour and up to MENTION_MAX_CHECKS_PER_DAY times a day for everyone. |
Voice notes add one more thing that leaves the bot, and it does not go to Gemini. The audio of each utterance goes to the PlaySafe speech-to-text hosts (ASR_HOSTS) as a WAV file, and only the text that comes back is sent to Gemini, as the meeting transcripts row above. The AI service is not involved in the speech step and nothing falls back to Gemini for speech. See Voice notes.
Switches
Section titled “Switches”GEMINI_API_KEY and the embedding switches are on Configuration (Embeddings). Without a key the AI service reports not configured, embeddings stay off, the summary scheduler idles, asking questions, ticket prompts and meeting transcripts are off, and a tag on a reply still starts a follow-up with no time. Embeddings run only when EMBEDDINGS_ENABLED is true and a key is set, and search by meaning follows the same rule. The startup lines that say which state the bot is in are in the Startup log.
With embeddings off, messages and members are still stored and full-text search still works. Calling the AI service without a key throws AI is not configured (GEMINI_API_KEY is missing).
The AI service
Section titled “The AI service”| Item | Value |
|---|---|
| Provider | Google Gemini |
| Models for answers | gemini-3.6-flash (default), gemini-3.5-flash, gemini-3.5-flash-lite. Any other model is rejected. |
| Embedding model | gemini-embedding-2 |
| Thinking levels | minimal (default), low, medium, high |
| Whole-call timeout | 120 seconds by default. A call can set its own |
| Embedding timeout | 60 seconds for each batch of up to 100 texts |
It returns plain text or structured data (JSON checked against a schema, a description of the fields it must have), and can call tools. A caller picks the model per call.
Request checks
Section titled “Request checks”A call that breaks these rules fails at once, before anything is sent:
| Rule | Value |
|---|---|
| Messages | At least one, none blank, and the last one from the user |
| System text | If present, not blank |
| Tool names | Lowercase letters, digits and underscores, starting with a letter, up to 64 characters, no duplicates, each with a description |
| Tool rounds | A whole number from 1 to 16 |
| Temperature | 0 to 2 |
| Output tokens, timeout | Positive whole numbers |
Tool result size (maxToolResultChars) | A whole number from 1 to 1,000,000 characters |
Time kept for the final call (finalReserveMs) | A whole number of milliseconds, from 0 up to below the timeout |
| Embedding texts | 1 to 2,048 per call, none blank |
| Embedding size | A whole number from 1 to 3,072. The bot always asks for 1,536 |
Summary calls
Section titled “Summary calls”A summary uses the tool loop and a structured answer: the default model, thinking level low, a 180 second timeout and up to 8 tool rounds. The three message tools only see the channels the reader can see. The two Linear tools only see the summary’s Linear team, and its project if one is set. A personal digest gets only the message tools.
| Tool | What it does |
|---|---|
list_messages | Recent messages in those channels, newest first. 50 by default, up to 100 per call. The result is cut to 12,000 characters and says truncated when more exist, so the model can page back with before. |
search_messages | Search within the summary period, up to 15 results. |
get_message_context | The messages around one message. |
list_linear_issues | Linear issues in one category for the period. |
get_linear_issue | One Linear issue by identifier. |
The two Linear tools answer linear_not_configured without a Linear key and no_linear_team when the summary has no team. How a summary is built, shown and delivered is on Summaries.
Ticket prompt calls
Section titled “Ticket prompt calls”Up to three calls per checked message and one more when a member submits the form. None use tools, all use gemini-3.5-flash-lite, and each must answer in a fixed shape:
| Call | When | Thinking | Time limit | Answer limit |
|---|---|---|---|---|
| Classification | Every message that reaches the AI | minimal | 20 seconds | 1,024 tokens |
| Verdict | Only when Linear search found similar tickets | low | 20 seconds | 256 tokens |
| Team suggestion | Only when the message is a proposal and its author is offered two or more teams. The answer is one team name | minimal | 10 seconds | 128 tokens |
| Label pick | When a member submits the form and the team has labels | minimal | 10 seconds | 128 tokens |
When a classification or verdict call times out, or the AI provider is unreachable, rate limited or failing (status 408, 429 or 500 to 599), or it rejects the key (status 401 or 403), new checks pause for 60 seconds and the bot logs [tickets] AI unavailable, pausing checks for 60 s. A team suggestion or a label pick never pauses anything. If either fails, the bot logs [tickets] team suggestion failed: <error class> or [tickets] label pick failed: <error class> and goes on without a suggestion or labels. A suggestion counts only when it names exactly one listed team, ignoring case and accents. How a prompt is decided and sent is on Ticket prompts.
Meeting transcript calls
Section titled “Meeting transcript calls”None use tools, and each must answer in a fixed shape:
| Call | When | Model | Thinking | Time limit | Answer limit |
|---|---|---|---|---|---|
| Summary | Every upload or voice meeting. One call per part of up to 200,000 characters, at most 8 parts and in practice two, run 3 at a time. Retried once at minimal thinking when the answer does not fit | The default model | low | 120 seconds, 5 minutes in all | 16,384 tokens |
| Merge | Only when more than one part had content | The default model | low | 120 seconds, within the same 5 minutes | 16,384 tokens |
| Ticket match | When the bot has a Linear key and the search found similar tickets. One call for all items and topics | gemini-3.5-flash-lite | minimal | 20 seconds | 2,048 tokens |
| Team suggestion | When the bot has a Linear key, the transcript names no usable team and there is new work. One call for the whole meeting | gemini-3.5-flash-lite | minimal | 10 seconds | 128 tokens |
| Label pick | When someone presses Create selected and the team has labels. One call per ticket | gemini-3.5-flash-lite | minimal | 10 seconds | 128 tokens |
The summary also asks for English search words for each item and topic, so Linear can be searched whatever the transcript’s language. It asks the model to classify each item as new_work or update_existing and, for an update, to give an update kind, a target status (backlog, todo, in_progress, in_review or none) and the words that name the ticket. An unknown value becomes none instead of failing the summary. Applying an update makes no call: it is a plain Linear write.
A failed ticket match means no match, a failed team suggestion means the team picker is not reordered, and a failed label pick means no labels. A summary that fails because the provider is unreachable, rate limited, failing or rejects the key pauses new uploads for 60 seconds and logs [meetings] AI unavailable, pausing for 60 s. Only the error class is logged. See Meeting transcripts.
Question calls
Section titled “Question calls”A question uses the tool loop and a structured answer. The bot routes it in code first: lookups use gemini-3.5-flash-lite with the 30 best matches preloaded, and period or aggregate questions use gemini-3.6-flash with wide tools. Both use thinking level low, a 90 second timeout, up to 8 tool rounds and the largest tool result size (1,000,000 characters), so wide reads reach the model whole.
The model gets up to six tools over messages and meetings, limited to what the asker can see (for a public answer, what the channel’s readers can see). It returns an answer, a found flag and up to 8 cited sources, which the bot checks before showing. The bot stops offering tools once less than a tenth of the question’s evidence budget is left, and always keeps 30 seconds of the timeout for the last call that writes the answer. See Asking the bot questions.
Tag reading call
Section titled “Tag reading call”When someone tags the bot, one call decides whether the message is a follow-up request, a question or neither, and what time a follow-up names. It uses no tools: gemini-3.5-flash-lite, thinking level minimal, a 10 second timeout and an answer limit of 160 tokens. The answer has one of three intents (follow_up, question or none) and a time, used only for a follow-up. The time is one of in N seconds, in N minutes, in N hours, in N days, tomorrow HH:MM or HH:MM, or empty, and the bot reads it the same way it reads /follow.
With no key, no budget left (20 per author per hour, MENTION_MAX_CHECKS_PER_DAY per day for everyone, kept in memory and reset on restart), nothing left to read after the mention is removed, or a failed call, a tag on a reply is treated as a follow-up with no time. A failure logs [follow-up] mention reading failed: <error class> and nothing else. See Follow-ups.
Untrusted text
Section titled “Untrusted text”Text that members wrote is never mixed into the instructions. Ticket prompts, questions, meeting transcripts and tag reading put it inside named tags and tell the model it is data, not instructions. Every angle bracket in it, and its HTML entity forms, is first replaced with a look-alike character, so the text cannot imitate or close a tag. Short fields such as names and titles are squeezed onto one line.
Structured answers
Section titled “Structured answers”The bot asks Gemini for JSON that fits the schema. A reply wrapped in a code fence is unwrapped before it is checked. If the first reply does not match the schema, the model is asked once more, with tools off, to reply again with valid JSON. If that also fails the call errors with model output did not match the schema. A reply that stopped because it hit the output limit is flagged as truncated, and usually fails the check for the same reason.
Callers that need one typed answer use a shared helper. It makes the call, checks the answer against the schema again, and throws The model returned an unusable <thing> if it does not fit, for example classification, verdict, team suggestion, label pick, issue match, summary, meeting summary, ask or mention reading. Nothing from the model’s reply is put in that message.
Gemini rejects a response schema with a 400 when nested array limits multiply (for example 4 sections of 8 items of 5 sources). The bot strips minItems and maxItems from the schema it sends, for the answer and for tool parameters, still enforces them when it checks the answer, and states the limits in the prompt.
Tool loop
Section titled “Tool loop”When the caller supplies tools, the model may call them and see the results before answering.
| Limit | Value |
|---|---|
| Rounds of tool calls | 8 by default, 1 to 16 |
| Tool calls in one turn | 8; extras get an error telling the model to retry with fewer |
| Tools running at once | 3 |
| One tool’s time | 30 seconds |
| One tool’s output | Cut at 20,000 characters, unless the request sets maxToolResultChars (1 to 1,000,000; questions use the maximum) |
After the last round, or at 80 percent of the timeout (or when finalReserveMs is left, if the request sets it), or when the caller says to stop offering tools, the model is called once more with tools off so it can finish. A tool error goes back to the model as a message, not as a crash:
| What the model sees | When |
|---|---|
unknown tool: <name> | It asked for a tool that does not exist. |
invalid arguments: ... or invalid_arguments | Its arguments do not fit the tool’s schema. |
tool timed out after 30000ms | The tool ran past its time. |
tool_failed | The tool threw. The bot logs [ai] tool <name> failed: <error class> and nothing else. |
tool result is not JSON-serialisable | The tool returned something that cannot be sent. |
aborted | The call was already being cancelled. |
{ output: ..., truncated: true } | The output was longer than the cap and was cut. |
If the whole call is cancelled or runs past its timeout, it fails with AI call aborted.
Failures and retries
Section titled “Failures and retries”A failed model call is retried up to 3 times, so 4 attempts. The retry applies to each call to Gemini, including each round of a tool loop and each embedding batch. Waits grow as about 0.5 to 1 second, 1 to 2 seconds, 2 to 4 seconds, with a random factor, capped at 30 seconds. If Gemini says how long to wait, that time is used. Retried: status 408, 429 and 500 to 599, and network errors. Not retried: blocked or empty answers, other statuses and aborts. Each retry logs [ai] retry attempt=... status=... delay=...ms.
The error a caller sees never contains prompt or reply text:
| Error | Meaning |
|---|---|
Gemini request failed (status N) | Gemini answered with an error status. 408, 429 and 5xx are retried first. |
Gemini request failed (network error) | The connection failed. Retried first. |
Gemini blocked the request (<reason>) | Gemini refused the prompt. |
Gemini stopped the response (<reason>) | A safety stop, even if some text arrived. Partial text is never used. |
Gemini returned no content (<reason>) | The reply had neither text nor tool calls. |
Gemini returned N embeddings for M texts, Gemini returned an embedding of N dimensions, expected M | An embedding reply of the wrong shape. |
Embeddings
Section titled “Embeddings”An embedding is a list of 1,536 numbers that captures what a message means, so search can find it by meaning. The bot stores these vectors in Postgres with pgvector (a Postgres extension for vectors) and an index tuned for finding close vectors fast. The model makes 3,072 numbers; the bot asks for 1,536 and scales each vector to length 1.
What is embedded
Section titled “What is embedded”A message is embedded when it is not deleted, not from a bot, a normal message or reply, and not blank. It needs a new vector when it has none, its text has changed, or the model has changed.
What text is sent
Section titled “What text is sent”One block per message, one item per line:
- The channel name as
#name, if known. - Context lines, only when the message is short (under 60 characters) or a reply: up to 3 earlier messages in the same channel, plus the message being replied to if it is in the same channel, first.
- The message as
Author: text.
The text of each context message is cut to 200 characters (the author name is added in front), and context is trimmed from the top (the replied-to message first, then the oldest earlier message) until it fits in 800. The whole block is cut to 6,000 characters. Bot, deleted and blank messages are never used as context. Gemini receives the block as title: none | text: <block>. A search phrase goes as task: search result | query: <phrase>, so both sides of the comparison are shaped for it.
Only edits trigger a new vector, not changes to context.
Two ways messages get embedded
Section titled “Two ways messages get embedded”| Path | When | Details |
|---|---|---|
| Queue | A person posts or edits a message live | Batches of 100, or after 5 seconds, whichever comes first, one batch at a time. Holds up to 5,000 ids; beyond that the oldest are dropped and the sweep recovers them. |
| Sweep | Every EMBEDDING_SWEEP_HOURS, first run 60 seconds after start | Catches new, edited, model-changed and backfilled messages, in the order they were first stored. At most 20 batches of 100, so 2,000 messages per sweep. One sweep at a time. |
A large backlog therefore takes several sweeps. The sweep logs [embeddings] sweep: embedded=... failed=... remaining=.... A full queue logs [embeddings] queue full, dropped N; the sweep recovers them.
A failed batch is logged and its messages stay waiting: a failed queue batch is logged as [embeddings] flush failed: ... and is not tried again by the queue, and a failed sweep batch as [embeddings] batch failed: .... A sweep stops after 3 failed batches in a row, and a sweep that fails as a whole logs [embeddings] sweep failed: .... A one-time script, embeddings:backfill, can embed everything at once, see Scripts and health.
Before saving, the bot checks that every vector has 1,536 finite numbers and that the count matches, or the batch fails with embedding response shape mismatch. It then locks the message rows while it saves, so a delete or an edit that lands at the same moment cannot leave a vector behind for text that is gone.
When a message is deleted or blanked its vector is deleted with it. On shutdown the queue flushes one last batch, aborting after 5 seconds.
Cost to know about
Section titled “Cost to know about”Each vector is 1,536 numbers stored as 2-byte values (halfvec(1536)), so 3,072 bytes plus the index. Size your database plan before embedding a large history.
- Search for how vectors are used.
- Summaries, Asking questions, Ticket prompts and Meeting transcripts for the features built on this service.
- Configuration for every setting.