Ticket prompts
A ticket prompt (a direct message that offers to file a feature idea as a Linear ticket) takes six steps: filter the message, ask an AI model whether it proposes a feature, search Linear for an existing ticket, suggest a team, claim the message, then send a direct message with a form. Only a submitted form writes to Linear. For the member’s view, see Ticket prompts.
Settings
Section titled “Settings”A pipeline run depends on a few settings, all listed in Configuration:
TICKET_PROMPTS_ENABLEDswitches new prompts on. It also needs both keys: when it is off.TICKET_PROMPT_DAILY_CAPlimits prompts per member in any rolling 24 hours.TICKET_PROMPT_MAX_CHECKS_PER_DAYlimits checks that reach the AI, across all members.GEMINI_API_KEYis needed for the detector. Without it no message is checked.LINEAR_API_KEYis needed to search and to create tickets. See Team, assignee and labels.REMINDER_ALLOWLIST, when set, limits checks to those authors.
What starts a check
Section titled “What starts a check”A check starts only for a new normal or reply message that the bot stored, in a channel it stores, in the one configured server. Edits, messages read back from history and direct messages never start one. There is no timer.
A check is one pass of this pipeline over one message. Checks run through one queue: 4 at a time, at most 200 in all and 10 per author, counting both waiting and running checks. Checks for one author run in order, one at a time. A message the queue refuses is dropped, and the bot logs at most one warning a minute, [tickets] queue full, dropped N. A shutdown drops what is waiting and aborts what is running.
The pipeline
Section titled “The pipeline”Each step can end the check. Cheap steps run first.
| Step | What happens | Ends the check when |
|---|---|---|
| 1. Switch | The runtime must be on and not paused. | Off, or paused. The message is dropped, not queued. |
| 2. Allowlist | The author must be allowed. | The list is set and the author is not on it. |
| 3. Prefilter | Cheap text rules, below. | The message fails a rule. |
| 4. Hourly limit | Spends one of the author’s 20 checks per hour. | The author has used 20 in the last hour. |
| 5. Prompt limit | The author’s prompts in the last 24 hours. This is the first step that runs from the queue, and it first checks that the message was not deleted. | At or over the daily cap, or the message was deleted. |
| 6. Global limit | Spends one check from the shared daily budget. | The budget is spent. |
| 7. Detector | The AI reads the message. | It is not a feature proposal. |
| 8. Linear search | Look for an existing ticket. | The AI names one as a duplicate. |
| 9. Claim | Insert the ticket_prompts row that reserves the message, then send the DM. | A row for the message already exists, or it was deleted. |
The text checked is the message as it arrived, not the stored copy.
The prefilter
Section titled “The prefilter”The prefilter rejects, with no AI call:
- Messages from bots, including webhooks.
- System messages. Only normal messages and replies pass.
- Messages with fewer than 8 words after stripping.
Before counting, the bot replaces fenced code, inline code, web links, custom emoji, mentions and Unicode emoji with a space, then splits the rest on spaces. A word needs at least one letter, so numbers and punctuation do not count. There is no maximum length, but the detector cuts the text to 4000 characters.
The limits
Section titled “The limits”Three limits count different things and are not merged. A rolling 24 hours is the 24 hours before now, not a calendar day.
| Limit | Value | What counts | Survives a restart |
|---|---|---|---|
| Checks per author per hour | 20, fixed | Every message that passed the allowlist and the prefilter and was accepted by the queue. A message the queue refuses gets its take back. | No, kept in memory |
| Prompts per member per day | TICKET_PROMPT_DAILY_CAP, rolling 24 hours | Rows with status prompted, creating, created, dismissed or failed. A duplicate row does not count. | Yes, counted from the database |
| Checks per day, all members | TICKET_PROMPT_MAX_CHECKS_PER_DAY, rolling 24 hours | Only checks that reach the AI. A message stopped by the prompt limit does not spend it. It is not refunded if the AI call then fails. | No, kept in memory |
When a message hits the prompt limit or the global limit, the check is skipped and nothing is logged. A failed prompt, for example a closed direct message, still counts toward the member’s daily cap.
The detector
Section titled “The detector”The detector asks gemini-3.5-flash-lite up to two questions. Each has a 20 second limit that also covers the AI service’s own retries (up to 3) and its one re-ask when the answer is not valid JSON.
- Classification. The message, cut to its first 4000 characters, is sent inside
<message>tags. Nothing else is sent: no name, no id, no channel. The model returns whether it is a proposal, a title, a description and a short search query. Bug reports, questions, status updates, jokes, praise and talk about existing tickets are not proposals. The call uses the lowest reasoning effort (thinking: minimal) and a 1024 token answer limit. - Verdict. Only when the Linear search found something. The draft and up to 5 tickets are sent. Each ticket description is cut to 300 characters. The model answers
duplicate,possibleornoneand names the tickets it means. It uses low reasoning effort and a 256 token answer limit.
Every < and >, and its HTML entity form, is replaced with a look-alike angle quote before sending, so a message or a ticket cannot open or close a tag. The system prompts tell the model that tagged text is data, not instructions. The calls for team suggestion and labels do the same.
The draft is cleaned before use. The title becomes one line of at most 200 characters, and a blank title means the message is not a proposal. The description is at most 4000 characters and may be empty.
The Linear search
Section titled “The Linear search”The search term is the model’s query, up to 100 characters, or the title if that is blank. It asks for up to 5 tickets, and leaves out archived tickets and tickets of private teams. It stops after 10 seconds.
| Result | What happens |
|---|---|
| No tickets found | The prompt goes out with no related tickets. No second call. |
| Search is rate limited or times out | The prompt goes out with no related tickets. The duplicate check is skipped. |
| Any other Linear error | The check fails and is logged. |
duplicate, and the model names a ticket that was found | No DM. A row with status duplicate is stored with that ticket. See below. |
duplicate, but names none that was found | The prompt goes out, listing the first 3 tickets found. |
possible | The prompt goes out, listing the named tickets, at most 3. |
none | The prompt goes out with no related tickets. |
A duplicate row means the message never gets a prompt, because the claim finds the row and does nothing. It does not count toward the daily cap.
Claim before the message
Section titled “Claim before the message”Before sending anything, the runtime asks Linear for its public teams, which also warms the cache the form uses. If there are none, it logs [tickets] no public Linear teams visible and stops.
Next comes the team suggestion. It runs only here, for a message already judged a proposal, so ordinary messages never pay for it.
Then it inserts a row with status prompted, the draft and the suggested team, if any. A unique index on the Discord message id means a second attempt finds the row and does nothing, so each message gets at most one prompt. A crash between claim and send loses that prompt, and it is never retried.
After the claim the bot checks once more whether the message was deleted. If so it clears the draft, marks the row failed with the reason message_deleted and sends nothing. Otherwise it sends the direct message, with no mentions and no link previews.
- If the send fails, the row becomes
failed, the draft is cleared and the reason is stored:dm_closedwhen the member’s direct messages are closed,discord_<code>for another Discord error, otherwise the error text cut to 300 characters. It is logged as[tickets] prompt not delivered: <reason> (message <id>). It is not retried. - If it succeeds, the bot logs
[tickets] prompted (message <id>). When the author was offered exactly one team and it uses cycles, the bot also starts loading that team’s cycle dates in the background, so the first form opens with dates.
AI and Linear text in the message is made inert: Markdown and brackets are escaped and links and invites are broken, so it cannot open a link, an invite or a mention. The message is at most 2000 characters. If the related tickets push it over, whole ticket lines are dropped from the end.
The form
Section titled “The form”Discord allows 3 seconds to open a form, so working out the offered teams (the member row and the team list together) gets 2.5 seconds minus the time already used. Each list is cached for 60 seconds. After that the bot serves the old list at once and refreshes it in the background, so only the first call waits for Linear. Opening the form never runs the name match. When the AI is configured, it also starts loading the labels of the first three offered teams in the background, so the submit finds them cached.
The form has a required Title of at most 200 characters and an optional Description of at most 4000.
- Team: a required drop-down, only when more than one team is offered (see Team, assignee and labels). Teams are sorted by name and the first 25 show, except that a suggested team is listed first so the cut cannot drop it, and is selected with the note
Suggested from your message. Change it if it is wrong.Past 25 teams the note readsShowing 25 of N teams. Ask an admin if yours is missing.and replaces the suggestion note, though the suggested team is still first and selected. A suggestion no longer among the offered teams changes nothing. - Cycle: an optional drop-down, always last, only when at least one offered team has cycles on in Linear. Options are
No cycle,Current cycleandNext cycle, starting onNo cycle. With exactly one offered team the options also show its cycle name and dates from a snapshot, in the member’s time zone or UTC. The form waits at most 700 milliseconds for the dates because they are cosmetic: when they are not ready the options are plain. The snapshot is dropped at the next cycle boundary (the moment a cycle ends or the next one starts) or after 10 minutes, and an ended cycle is never shown as current or a started one as next. See Cycle.
Only the author may press the buttons. The allowlist is not checked here, so a prompt already sent works whatever the list says now. No timer expires a prompt, but the 30-day clean-up closes it: once the draft is cleared, Create ticket answers Your original message was deleted, so I closed this prompt. even though the message still exists. See Retention and cleanup.
On submit the bot checks, in this order, before it claims anything:
- The row still has a draft. If not, the direct message becomes
Your original message was deleted, so I closed this prompt. - The title is not blank.
- The offered teams can be worked out again from the cached lists. Linear is asked only when the bot holds none yet, for example after a restart. If that fails, the reply is
Linear is slow right now. Press Create ticket again in a moment.and the bot logs[tickets] team list failed on submit: <class>. - The team is valid: the single offered team, or the submitted choice, which must be one of the offered teams. Otherwise the reply is
That team is not available. Press Create ticket and choose another. - The author is still in the server.
A rejected submit claims nothing and changes no status.
Creating the ticket
Section titled “Creating the ticket”The claim moves the row from prompted to creating and sets a Linear issue id (a random UUID) if the row has none. The bot then asks Linear to create an issue with that id, the team, the typed title and the typed description followed by a footer, and the assignee, labels and cycle when there are any. These are worked out together, so one slow lookup does not add to another. No project, priority or state is set, so Linear applies the team’s default state for new issues. The creator shown in Linear is the owner of LINEAR_API_KEY.
The footer is a blank line, ---, and Proposed by <display name> (Discord id <id>) in Discord: <link to the message>. The ticket and the link are visible to everyone in the Linear workspace.
The create, the read-back of the issue and the recovery lookup each have a 20 second limit.
If Linear rejects the create as invalid input while an assignee, labels or a cycle were sent, the bot tries once more with the same issue id and without them, and logs [tickets] create retried without extras. A stale link, a retired label or an ended cycle cannot block the ticket. The retried ticket has no assignee, labels or cycle, and the direct message ends with Filed in <team>. plus a line naming what was left out, for example Linear rejected some details, so it was filed without its labels or cycle. The line names only what the bot had sent. Any other failure is not retried this way.
On success the row becomes created, stores the Linear id, identifier and url, and clears the draft. The direct message is edited to show the ticket, a last line saying where it went (Assigned to you in <team>. or Filed in <team>., with the cycle after a dot when there is one) and a link button. While the bot works, the message gets the line Creating the ticket… added at the end, unless the text is too close to Discord’s 2000 character limit to fit it. The original text is put back if the create fails. If saving the row fails, the ticket still exists and the direct message still shows it. The bot logs [tickets] finishing the row failed: <class> and the row stays creating.
On failure the claim is released back to prompted and what the member typed is saved as the new draft, so the form comes back with their edits. The reply depends on the failure:
| Failure | What the member is told |
|---|---|
| Timeout | It cannot yet say whether the ticket exists. Press Create ticket to check. |
| Authentication, forbidden or invalid input | Linear rejected it. Tell an admin. Invalid input with an assignee or labels is retried once without them first, as above. |
| Anything else, including a rate limit | Nothing was filed. The text is saved. Try again. |
Failures are logged as [tickets] create failed: <kind> (<name>) with kind rate_limited, auth, invalid, timeout or other. Text is never logged.
Team, assignee and labels
Section titled “Team, assignee and labels”A member’s link to a Linear user, made by name or by an admin, decides the teams and the assignee. How to fix a link is on Linking members to Linear; the matching rule is under How members are matched.
Teams offered. Worked out when the form opens and again on submit:
| The author | Teams offered | Assignee |
|---|---|---|
| Linked, and Linear lists at least one team for that user | Those teams. Private ones are included, because the user is a member | The linked Linear user |
| Linked, and Linear lists no team, or the Linear user is deactivated or unknown | All public teams | The linked Linear user |
| Not linked | All public teams | None |
The submit step may match an unlinked author by name first. That changes the assignee and the stored link, but never the offered teams: they follow the link the form was opened with. If the match or the lookup fails, the bot logs [tickets] auto-link failed: <class> and files the ticket unassigned.
Suggested team. After the detector and the Linear search have cleared the message, when the author is offered two or more teams, one more call to gemini-3.5-flash-lite may name the team the message clearly belongs to. The offered teams are looked up at that moment with a 1.5 second budget; a slow or failed lookup means no suggestion and the prompt still goes out. The call has a 10 second limit, minimal reasoning effort and a 128 token answer limit.
- The message (first 4000 characters) and each offered team’s name and one-line Linear description (cut to 300 characters) are sent inside tags. The model is told the tagged text is data, to answer with one listed name or an empty string, and never to guess.
- The answer counts only when it matches exactly one offered team by name, ignoring case and accents. Anything else is dropped. Team descriptions are not stored.
- The team id is stored on the prompt row as
draft_team_idand cleared with the draft. A failed call is logged as[tickets] team suggestion failed: <class>, means no suggestion, and never pauses checks. - It only chooses what the form has selected. Submit still resolves the team from the offered list, and the author can change it. On submit, when the author had a choice of team, the bot logs counts only:
[tickets] team suggestion kept,[tickets] team suggestion changedor[tickets] team no suggestion.
Labels. After the checks pass and the claim succeeds, if the AI is configured, the bot reads the labels usable on the chosen team: the team’s own labels plus the workspace labels, minus group labels and retired ones. It then asks gemini-3.5-flash-lite to choose up to 3 that clearly fit. The call has a 10 second limit, minimal reasoning effort and a 128 token answer limit.
- The ticket title and description (cut to 1000 characters) and the label names are sent inside tags. The model is told the tagged text is data.
- Names it returns are matched to the real labels, ignoring case. Names that do not exist are dropped, duplicates collapse and at most 3 remain. The bot never creates a label.
- If the team has no labels, there is no AI call. If the label list or the AI call fails, there are no labels, the bot logs
[tickets] label list failed: <class>or[tickets] label pick failed: <class>, and the ticket is still created.
A cycle is optional and defaults to none.
- It is resolved against the team the ticket is filed in, at submit time, with one cached
listTeamCyclesread (cached 60 seconds, and skipped once a cycle boundary has passed).Current cycleis the cycle running at that moment andNext cyclethe one after it. A current cycle that has ended, or a next cycle that has started, is dropped even if the cache still holds it. - A member who changes the team in the form gets that team’s cycle.
- When no cycle can be used the ticket is filed without one and a small-print line (a Discord
-#line) says why: the team has cycles off, has no current cycle, has no next cycle, or its cycles could not be read. The lookup failure is logged as[linear] cycle lookup failed: <class>. - A cycle Linear rejects goes through the retry without extras, described under Creating the ticket.
- Team and cycle names in the message come from Linear, so they are escaped and cut to 64 characters.
How members are matched
Section titled “How members are matched”The bot compares each member’s stored server display name with the name and display name of each active Linear user, leaving out Linear apps. It reads at most the first 100 users, so a larger workspace cannot be matched past that, and caches the list for 60 seconds. Comparison ignores case, accents and extra spaces and is otherwise exact. There is no partial, first-name or close match, because a wrong link would assign tickets to the wrong person.
Only members who are present, have no link and have never been decided on are considered, and only Linear users nobody holds. A member is linked only when the match is one-to-one: they have exactly one candidate and that Linear user is a candidate for no other member. Anything else with a candidate is unsure. A member with no candidate is unmatched.
| When it runs | What is written |
|---|---|
| Once at startup, after the first full member sync, when Linear is connected. A failure is logged and ignored | Every one-to-one match, marked auto |
/linear-admin list and /linear-admin sync | The same |
| When an unlinked, undecided author submits a form | Only that author’s match. Other members are read but not written |
Writing a link skips a row that was decided in the meantime, and ignores a Linear user someone else took. The startup line is [linear] auto-linked N members, M unsure, K unmatched, with counts only. A failed startup match is logged as [linear] startup auto-link failed: <error>. An admin’s link or unlink marks the row manual, and the match never touches it again. See Data.
What the bot asks Linear
Section titled “What the bot asks Linear”Every Linear read has a 10 second limit. Lists read at most two pages of 50, so 100 items.
| Read | What it returns | Cache |
|---|---|---|
| Search | Up to 5 tickets, not archived, public teams only | None |
| Public teams | Teams that are not private, sorted by name, with a one-line description cut to 300 characters | 60 seconds, then served stale while refreshing |
| Teams of a user | The user’s teams, private included, sorted by name. A deactivated or unknown user has none | 60 seconds, then served stale while refreshing |
| Users | Active users that are not apps: id, name and display name. Emails are never read | 60 seconds |
| Labels of a team | The team’s labels and the workspace labels, without groups or retired ones | 60 seconds |
| Cycles of a team | The current and next cycle | 60 seconds, dropped at a cycle boundary. The form’s date preview uses a snapshot kept up to 10 minutes |
Why a retry cannot make a second ticket
Section titled “Why a retry cannot make a second ticket”The bot chooses the Linear issue id before the create call and reuses it on every retry. After any failure it looks the issue up by that id and returns it if Linear did create it, so a timeout cannot cause a duplicate even when the answer was lost.
A double submit is safe too. Only one submit can move the row from prompted to creating. The second claims nothing and is told Still creating that ticket. Give it a few seconds., or Already created once the ticket exists.
A stuck create
Section titled “A stuck create”A creating row not updated for 2 minutes is stale (assumed abandoned), for example after a crash mid-create. The window is longer than the worst create, three steps of 20 seconds. The member can then press Create ticket again, and the bot claims the row with the same Linear issue id, so the retry resolves to the ticket Linear already made or creates it once. A fresh creating row answers Still creating that ticket and keeps its buttons.
Status lifecycle
Section titled “Status lifecycle”Statuses are stored as text. No code removes a row.
| Status | Set when | Next |
|---|---|---|
prompted | The claim, before the direct message. | creating, dismissed or failed |
creating | A valid form submit. | created, or back to prompted on a failure |
created | The Linear issue exists. | Final |
dismissed | No thanks. | Final |
failed | The direct message was not delivered, or the message was deleted during the claim. | Final |
duplicate | The model matched an existing Linear ticket. No direct message. | Final |
Pressing Create ticket on a final row removes the buttons and says why. No thanks clears the draft and the direct message shows that no ticket was created.
When the message or channel is deleted
Section titled “When the message or channel is deleted”Deleting the source message, or its channel or thread, clears the draft title, description and suggested team of the matching row, even for messages the bot never stored. It happens in the same step as the scrub of the message text.
- A check still in flight stops at three points if the message or channel was deleted: before the prompt limit, just before the claim and just after it.
- A prompt already sent keeps its status, with no draft.
Create ticketthen edits the message toYour original message was deleted, so I closed this prompt.A form already open ends the same way, and nothing is filed. - Deleting a message after the ticket exists does not remove the ticket from Linear.
- Editing the source message changes nothing about an existing prompt.
The bot remembers the last 1000 deleted message ids and the last 1000 deleted channel ids in memory, so a check in flight can see them. A restart clears them.
Retention and cleanup
Section titled “Retention and cleanup”Draft text and the suggested team are cleared when the ticket is created, the prompt is dismissed, the prompt fails, the message or its channel is deleted, and after 30 days. The rows stay: ids, status, Linear id, identifier and url, and failure reason. The member’s Linear link lives in members and is not part of this clean-up.
The 30-day clean-up runs only when the bot starts, never on a timer, and even with the switch off, so a draft older than 30 days stays usable until the next start. If it fails, the bot logs [tickets] startup prune failed: <error> and carries on. It needs the ticket_prompts table from migration 0009. See Data.
When the AI is down
Section titled “When the AI is down”If a check fails because the AI service is unreachable, rate limited or failing (HTTP 408, 429 or any 5xx), times out, or rejects the key (HTTP 401 or 403), the bot pauses all new checks for 60 seconds and logs [tickets] AI unavailable, pausing checks for 60 s. Messages that arrive during the pause are dropped, not queued. Checks already waiting still run. The pause ends on its own, and a restart clears it.
A shutdown or a refusal about one request, such as HTTP 400, does not pause. Any other failure is logged as [tickets] check failed: <class>, with the status when there is one, never message text.
When it is off or a key is missing
Section titled “When it is off or a key is missing”The bot logs one line at startup, after the summaries line. There is no ticker.
| State | Startup log | What happens |
|---|---|---|
TICKET_PROMPTS_ENABLED is false | [tickets] disabled | No message is checked. No new prompt. |
On, but GEMINI_API_KEY or LINEAR_API_KEY is missing | [tickets] off: needs GEMINI_API_KEY and LINEAR_API_KEY | No message is checked. |
| On, both keys set | [tickets] enabled, cap 3/day per author, max 2000 checks/day, allowlist 0 members | Checks run. The numbers are your settings. |
The button handler is always registered, so old prompts keep working after the switch is turned off.
| After you turn the switch off | What the member sees |
|---|---|
| Both keys still set | Create ticket, the form and No thanks all still work. |
GEMINI_API_KEY removed | The same. Buttons need only Linear. |
LINEAR_API_KEY removed or blank | Every button and the form answer Ticket prompts are turned off., including No thanks. |
The switch stops new prompts. It does not close the ones already sent.
- Ticket prompts for the member’s view and every reply.
- Linking members to Linear to fix a link.
- Configuration for the environment variables.