When someone mentions your Linear app or delegates an issue to it, Linear
opens an agent session. The Linear adapter (experimental) turns that session
into an ordinary thread: the session event arrives as a MessageEvent, and
Thread.Post answers with an agent response. Thoughts, actions,
elicitations, errors, plans, and external links go through typed adapter
access (ADR 0008).
Start from the runnable example,
examples/linear-agent-hello-world.
Its README covers the Linear OAuth app setup, the webhook configuration, and
common setup problems.
linearAdapter, err := linear.New(ctx, linear.Options{
WebhookSecret: os.Getenv("LINEAR_WEBHOOK_SECRET"),
ClientCredentials: linear.ClientCredentials{
ClientID: os.Getenv("LINEAR_CLIENT_ID"),
ClientSecret: os.Getenv("LINEAR_CLIENT_SECRET"),
// Scopes default to: read, write, app:mentionable, app:assignable.
},
})On Init the adapter exchanges client credentials for an app-actor token,
verifies the granted scopes, and discovers the app's own identity so
self-authored activities are filtered before routing.
New sessions and follow-up prompts route through the normal hooks. On a
session thread, Thread.Post creates a response activity, which tells
Linear the session is complete. Post it only when the answer is final:
bot.OnNewMention(func(ctx context.Context, ev *chat.MessageEvent) error {
answer, err := solve(ctx, ev.Message.Text) // your actual work
if err != nil {
return err
}
_, err = ev.Thread.Post(ctx, chat.Markdown(answer))
return err
})To show progress before the final answer, post a thought first (next sections).
Linear expects a first activity within about 10 seconds of a session event, and more activity within about 30 minutes. Enable deferred dispatch so the webhook acknowledgement no longer waits for the real work. Then, in the handler:
- Post a quick thought (
PostThought) right away. Not a response: a response ends the session. - Do the work, then post the final answer with
Thread.Post.
Everything beyond a plain response goes through typed adapter access. Each call can fail (validation, auth, rate limiting), so check every error before posting the next activity.
Thoughts, actions, and session updates keep the session open and show progress:
la, ok := chat.AdapterAs[*linear.Adapter](bot, "linear")
if !ok {
return errors.New("linear adapter is not registered")
}
// Ephemeral progress ("thinking...") activity.
if _, err := la.PostThought(ctx, ev.Thread.ID(), "Reading the issue history."); err != nil {
return err
}
// A tool-call style action with a result.
if _, err := la.PostAction(ctx, ev.Thread.ID(), linear.ActionInput{
Action: "ran",
Parameter: "go test ./...",
Result: "ok",
}); err != nil {
return err
}
// Maintain the session's plan and external links.
if err := la.UpdateSession(ctx, ev.Thread.ID(), linear.AgentSessionUpdateInput{
Plan: []linear.PlanStep{
{Title: "Reproduce the bug", Status: "pending"},
{Title: "Fix and test", Status: "pending"},
},
}); err != nil {
return err
}A session completes with exactly one completion signal: a response
(Thread.Post), an elicitation, or an error. Pick one; never post two in
the same turn:
if needsInput {
// Ask the user a question (optionally with a select/auth signal).
_, err := la.PostElicitation(ctx, ev.Thread.ID(), linear.ElicitationInput{
Body: "Which environment should I deploy to?",
})
return err
}
if buildFailed {
// Terminal failure state.
_, err := la.PostError(ctx, ev.Thread.ID(), linear.ErrorInput{
Body: "The build failed; see the attached log.",
})
return err
}
_, err := ev.Thread.Post(ctx, chat.Markdown(answer)) // final response
return errUsers can press Stop on a session. It arrives as a prompt carrying the
human-to-agent stop signal; Linear expects the agent to halt immediately
and then confirm with one final response (or error) activity. The worked
example detects and confirms it in one helper:
func confirmStop(ctx context.Context, ev *chat.MessageEvent) (bool, error) {
raw, ok := linear.RawMessageFrom(ev.Message)
if !ok || !raw.StopRequested() {
return false, nil
}
_, err := ev.Thread.Post(ctx, chat.Text("Stopping as requested — no further changes will be made."))
return true, err
}Call it first in every message handler and return when it reports a stop.
Stop cannot interrupt a running handler. The stop prompt is an ordinary
event on the session's thread, so the thread lock serializes it behind the
handler that is already running. ConcurrencyDrop discards it;
ConcurrencyQueue delivers it only after the running handler returns. The
runtime has no preemption hook
(ADR 0015 rejects one for v0.x).
Two patterns work:
- Short turns. Split long work into short handler turns, and start each
turn with a stop check like
confirmStop. - An out-of-band flag. Receive the stop somewhere outside the runtime's serialized dispatch — for example your own Linear webhook endpoint or an admin API — and set a cancellation flag that your handlers poll.
The rest of this page walks through complete interaction loops. Every code
block comes from the tested example
(examples/linear-agent-hello-world/capabilities.go),
and a documentation test keeps them in sync. linearAgentAccess is the
example's small interface over *linear.Adapter (obtained with
chat.AdapterAs, as above), so the helpers can be tested against a fake.
When work starts from an external trigger (a failing build, a cron job) and
the agent was neither mentioned nor delegated, create the session yourself.
CreateSessionOnIssue wraps Linear's agentSessionCreateOnIssue mutation and
returns a CreatedAgentSession whose ThreadID behaves exactly like a
webhook-minted one:
func startProactiveSession(ctx context.Context, la linearAgentAccess, issueID, dashboardURL string) (chat.ThreadID, error) {
created, err := la.CreateSessionOnIssue(ctx, linear.CreateSessionOnIssueInput{
IssueID: issueID, // a UUID or an identifier such as "ENG-123"
// Seeding externalUrls also keeps the fresh session from being marked
// unresponsive before the first activity arrives.
ExternalURLs: []linear.ExternalURL{{URL: dashboardURL, Label: "Run dashboard"}},
})
if err != nil {
return "", err
}
if _, err := la.PostThought(ctx, created.ThreadID, "Investigating this issue."); err != nil {
// The session already exists on Linear's side (and the seeded external
// URL keeps it responsive), so return its handle with the error: the
// caller can retry the activity or end the session cleanly instead of
// leaking it — re-running the whole helper would create a duplicate.
return created.ThreadID, err
}
return created.ThreadID, nil
}CreateSessionOnComment roots the session on an existing issue comment
instead. In multi-tenant mode (an InstallStore configured), use
CreateSessionOnIssueForTenant / CreateSessionOnCommentForTenant with the
target organization id — proactive creation has no inbound webhook to resolve
the tenant from.
SuggestRepositories wraps Linear's issueRepositorySuggestions query: pass
the candidate repositories the agent already has access to, and Linear ranks
them with confidence scores using issue, session, guidance, and internal
signals. Proceed when confident; otherwise pair the shortlist with a select
elicitation:
func chooseRepository(ctx context.Context, la linearAgentAccess, ev *chat.MessageEvent, pending *pendingSelections, candidates []linear.CandidateRepository) error {
suggestions, err := la.SuggestRepositories(ctx, ev.Thread.ID(), candidates)
if err != nil {
return err
}
if best := bestSuggestion(suggestions); best != nil && best.Confidence >= 0.8 {
_, err := la.PostThought(ctx, ev.Thread.ID(), "Working in "+best.RepositoryFullName+".")
return err
}
return offerRepositoryChoice(ctx, la, ev.Thread.ID(), pending, suggestions)
}(pendingSelections is the example's small take-once per-thread registry of
offered option values — see the select section below.)
A select-signal elicitation renders the options natively in Linear. It is a
completion signal: the session waits for the user after you post it.
func offerRepositoryChoice(ctx context.Context, la linearAgentAccess, threadID chat.ThreadID, pending *pendingSelections, suggestions []linear.RepositorySuggestion) error {
if len(suggestions) == 0 {
_, err := la.PostElicitation(ctx, threadID, linear.ElicitationInput{
Body: "I couldn't match a repository — which one should I work in?",
})
return err
}
options := make([]linear.SelectOption, 0, len(suggestions))
values := make([]string, 0, len(suggestions))
for _, s := range suggestions {
option := repositoryOptionValue(s)
options = append(options, linear.SelectOption{Value: option, Label: option})
values = append(values, option)
}
_, err := la.PostElicitation(ctx, threadID, linear.ElicitationInput{
Body: "Which repository should I work in?",
Signal: "select",
SignalMetadata: linear.SelectSignalMetadata{Options: options},
})
if err != nil {
return err
}
// Record what was offered — and by which workflow — so the next follow-up
// on this thread is interpreted as the answer (see handleSelection).
pending.set(threadID, pendingSelection{Kind: selectionKindRepository, Values: values})
return nil
}Qualify option identities with everything needed to disambiguate them — here
the Git host, so github.com/acme/backend and gitlab.example.com/acme/backend
stay distinct choices.
The user's answer arrives as a regular follow-up prompt (routed to
OnSubscribedMessage on a subscribed thread): a picked option delivers the
option's value as the message text, but users may instead reply in free
text, which dismisses the elicitation. Match known values and let everything
else fall through to your normal prompt handling:
func handleSelection(ctx context.Context, ev *chat.MessageEvent, sel pendingSelection) (bool, error) {
answer := strings.TrimSpace(ev.Message.Text)
for _, value := range sel.Values {
if answer != value {
continue
}
var ack string
switch sel.Kind {
case selectionKindDeploy:
ack = "Deploying to **" + value + "** — I'll report back here."
case selectionKindRepository:
ack = "Working in **" + value + "** — I'll open a pull request here when I'm done."
default:
ack = "Got it: **" + value + "**."
}
_, err := ev.Thread.Post(ctx, chat.Markdown(ack))
return true, err
}
return false, nil
}The registry stores which workflow asked (pendingSelection.Kind) alongside
the offered values, so a repository choice continues the repository workflow
instead of being misread as, say, a deployment target.
Since free-text replies are natural language, a production agent should involve its LLM when interpreting an unmatched answer rather than failing.
Only interpret a follow-up as an answer while a choice is actually pending on
that thread — otherwise a later message that happens to equal an option value
would be misread as a selection. The example keeps a small per-thread registry
(pendingSelections in capabilities.go) and records the offered values when
it posts the elicitation. An unmatched free-text reply consumes the pending
state for good (Linear dismisses the elicitation UI), but a matched choice
whose acknowledgement fails to post is re-registered — under DispatchDeferred
a handler error is only observed, never redelivered, so the user's retry must
still be interpreted as an answer:
if sel, ok := pending.take(ev.Thread.ID()); ok {
if handled, err := handleSelection(ctx, ev, sel); handled {
if err != nil {
pending.set(ev.Thread.ID(), sel)
}
return err
}
}A confirmed stop also abandons any pending selection (newFollowUpHandler in
the example): a stopped session will not answer its elicitation, so a later
message must not be misread as a choice.
An auth-signal elicitation makes Linear render an ephemeral "Link account"
button pointing at your auth flow. signalMetadata.url is your account
linking URL; the optional userId restricts the prompt to one Linear user:
func requireAccountLink(ctx context.Context, la linearAgentAccess, threadID chat.ThreadID, authURL, linearUserID string) error {
_, err := la.PostElicitation(ctx, threadID, linear.ElicitationInput{
Body: "Please link your account to continue.",
Signal: "auth",
SignalMetadata: linear.AuthSignalMetadata{
URL: authURL,
UserID: linearUserID, // optional: restricts the prompt to one user
ProviderName: "Example CI",
},
})
return err
}The follow-up is the part that differs from select: Linear sends no
webhook when the user completes the auth flow. Your own auth callback is
the trigger. Store the session's ThreadID (for example, keyed by the OAuth
state parameter) before posting the elicitation, then resume from the
callback — a thought both resumes the session and dismisses the ephemeral
auth UI:
func resumeAfterAccountLink(ctx context.Context, bot *chat.Chat, la linearAgentAccess, threadID chat.ThreadID) error {
if _, err := la.PostThought(ctx, threadID, "Account linked — resuming."); err != nil {
return err
}
thread, err := bot.Thread(ctx, threadID)
if err != nil {
return err
}
_, err = thread.Post(ctx, chat.Markdown("All set — the account is linked and the task is done."))
return err
}If the user replies in the session instead of completing the link, the reply arrives as a normal follow-up prompt — handle it like any other message.
Surface a pull request or dashboard on the session as work progresses.
AddExternalURLs appends without replacing existing links (ExternalURLs
replaces the whole list); Linear also treats the update as session activity:
func publishPullRequest(ctx context.Context, la linearAgentAccess, threadID chat.ThreadID, prURL string) error {
return la.UpdateSession(ctx, threadID, linear.AgentSessionUpdateInput{
AddExternalURLs: []linear.ExternalURL{{URL: prURL, Label: "Pull request"}},
})
}The adapter also takes part in plain issue comments, outside agent sessions
(ADR 0013). A comment that
@-mentions your app arrives on a comment thread, and Thread.Post replies
in that comment thread. Enable the Comment webhook category to receive
them.
Normal routing applies: a mention goes to OnNewMention only while the
thread is unsubscribed. Once you subscribe, every comment in the thread,
mention or not, goes to OnSubscribedMessage, so do not put mention-only
logic solely in OnNewMention. Agent-activity methods (PostThought,
UpdateSession, ...) fail on comment threads; they only work inside agent
sessions.
The Linear adapter is experimental, and Linear's agent API is itself in
developer preview. Some operations, such as moving an issue through its
workflow states, still need the GraphQL escape hatch instead of a typed
helper. The capability list tracks them.