Skip to content

Latest commit

 

History

History
416 lines (355 loc) · 16.1 KB

linear-agent-sessions.md

File metadata and controls

416 lines (355 loc) · 16.1 KB

How To Run Linear Agent Sessions

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.

Construct The Adapter

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.

Handle Sessions Like Any Thread

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).

Mind The Timing Contract

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:

  1. Post a quick thought (PostThought) right away. Not a response: a response ends the session.
  2. Do the work, then post the final answer with Thread.Post.

Use The Full Activity Surface

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 err

Users 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.

Worked Capability Loops

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.

Start A Session Proactively

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.

Pick A Repository With Suggestions

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.)

Offer Choices With A Select Elicitation

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.

Ask The User To Link An Account (Auth Elicitation)

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.

Publish Progress Links With externalUrls

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"}},
	})
}

Generic Issue Comments

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.

Known Gaps

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.