Linear is an optional communication interface for users to talk to Ava and supply context about projects, issues, and work. Ava acts on Linear only when she is @mentioned or the issue is assigned to her — she does not chase ambient activity. When that gate passes, LinearPlugin publishes the event with an explicit skillHint: linear_agent_respond, RouterPlugin dispatches it to Ava, and her reply comes back as a Linear agent-activity or comment (posted as Ava when her OAuth token is wired).
The plugin also exposes outbound mutations so agents can file or update Linear tickets as part of their work.
Overview
The flow in one sentence: a Linear event hits the webhook → LinearPlugin verifies the HMAC signature and publishes message.inbound.linear.{issue,comment,project,agent_session}.* → for @mentions and assigned sessions it stamps skillHint: linear_agent_respond → RouterPlugin dispatches agent.skill.request to Ava → her response publishes to linear.reply.{issueId} (or linear.agent_activity.{sessionId}) → LinearPlugin posts it back.
Linear inbound is skillHint-gated, never keyword-routed. RouterPlugin only dispatches a Linear event that carries an explicit skillHint; Linear content is never keyword-matched. An un-hinted Linear event is dropped. This prevents GitHub-domain skills (e.g. pr_review) from being pulled onto Linear tickets by a stray keyword. LinearPlugin sets skillHint: linear_agent_respond for issueMention/issueCommentMention notifications and for agent-session events on issues assigned to Ava — nothing else gets a hint, so nothing else is dispatched.
No cross-plugin dependencies. A second plugin that wants "Linear issue → some agent" just subscribes to message.inbound.linear.issue.created and fires the appropriate skill — it doesn't touch LinearPlugin at all (see Linear → proto bridge below).
Setup
1. Create a Linear webhook
In Linear: Settings → API → Webhooks → Create webhook.
| Field | Value |
|---|---|
| URL | https://<your-public-host>/webhooks/linear (port 8084 by default) |
| Resource types | Issue, Comment, Project (pick whichever you want the bus to see) |
| Signing secret | Generated by Linear — copy it to LINEAR_WEBHOOK_SECRET |
The webhook server listens on port 8084 by default (override with LINEAR_WEBHOOK_PORT). Put a reverse proxy or tunnel in front so Linear can reach it over HTTPS.
2. Create a personal API key (optional — for outbound)
In Linear: Settings → API → Personal API keys → Create key.
Store it as LINEAR_API_KEY. Without it, outbound mutations (comments, issue create/update) are disabled but inbound webhooks still work.
3. Register channels
Add one or more entries to workspace/channels.yaml:
channels:
# Route every Linear event in the ENG team to Ava
- id: linear-eng-triage
platform: linear
channelId: "ENG" # Linear team key
agent: ava
conversation:
enabled: true
timeoutMs: 600000
# A single Linear issue owned by a specific agent
- id: linear-single-ticket
platform: linear
channelId: "ENG-142" # Linear issue identifier OR uuid
agent: quinnchannelId is matched against source.channelId on the inbound message. For issue and comment events, LinearPlugin sets channelId to the team key (e.g. ENG); for project events, to the project id.
4. Env vars
| Variable | Required | Description |
|---|---|---|
LINEAR_API_KEY | For outbound | Personal API key for GraphQL mutations |
LINEAR_WEBHOOK_SECRET | For inbound | HMAC-SHA256 signing secret from Linear webhook config. REQUIRED when NODE_ENV=production or WORKSTACEAN_PUBLIC_BASE_URL is set — LinearPlugin refuses to start without it (open-relay safety). Unset is allowed in dev only and disables signature verification. |
LINEAR_WEBHOOK_PORT | No | Webhook server port (default: 8084) |
The plugin is condition-gated on either env var being present — with neither set, it doesn't install.
Topic Contract
Inbound (Linear → bus)
| Topic | Fires on | Key payload fields |
|---|---|---|
message.inbound.linear.issue.created | Issue created | issueId, identifier, title, description, priority, state, teamKey, projectId, assigneeId, labels, url |
message.inbound.linear.issue.updated | Issue updated | same shape as created |
message.inbound.linear.issue.removed | Issue deleted | same shape |
message.inbound.linear.comment.created | Comment created | commentId, issueId, issueIdentifier, body, userId, teamKey, url |
message.inbound.linear.comment.updated | Comment edited | same shape |
message.inbound.linear.project.created | Project created | projectId, name, description, state, creatorId |
message.inbound.linear.project.updated | Project updated | same shape |
Every inbound message carries source.interface = "linear", source.channelId (team key / issue id / project id), and reply.topic = linear.reply.{issueId} (with format: "markdown" so agents emit Linear-renderable comment bodies) when applicable.
Inbound webhooks are validated against a Zod envelope schema — a malformed Linear payload is rejected with HTTP 400 before reaching the bus. Replay protection rejects events whose webhookTimestamp is more than 5 minutes off real time.
Outbound (bus → Linear)
| Topic | Payload | Effect |
|---|---|---|
linear.reply.{issueId} | { text } | Posts a comment. Result published on linear.reply.result.{correlationId} |
linear.update.issue.{issueId} | { stateName?, priority?, assigneeId?, labelIds? } | Mutates the issue. Result published on linear.update.issue.result.{correlationId} |
linear.create.issue | { teamKey, title, description?, priority?, assigneeId?, labelIds?, stateName? } | Creates a new issue. Result published on linear.create.issue.result.{correlationId} |
Result-topic shape: every outbound mutation publishes a {family}.result.{correlationId} event with { success: boolean, error?: string, issueId?: string }. A linear.reply.{issueId} that hits a Linear rate-limit, has an empty body, or comes from a revoked API key surfaces as a linear.reply.result.{cid} with success: false, error: "..." — instead of silently swallowing the failure as the V1 plugin did.
Priority semantics: priority: "none" on linear.update.issue.* sets Linear's "No priority" (value 0), which is a real Linear value distinct from "leave unchanged." Pass priority: undefined (omit the field) to leave the existing priority untouched.
Multi-layer conversation example
User @mentions Ava in a comment: "@Ava can you plan the migration?"
↓
Linear webhook POST /webhooks/linear (agent notification: issueCommentMention)
↓
LinearPlugin publishes: message.inbound.linear.* with
↓ { skillHint: "linear_agent_respond", issueId, ... }
↓ (mention → hinted; un-mentioned ambient events get no hint and drop)
↓
RouterPlugin sees the explicit skillHint and publishes:
↓ agent.skill.request
↓ { skill: "linear_agent_respond", targets: ["ava"], content: "..." }
↓
SkillDispatcherPlugin routes to Ava → Ava runs → returns response
↓
Response published on: linear.reply.{issueId} (or linear.agent_activity.{sessionId})
↓
LinearPlugin outbound subscriber → posts as Ava (actor=app OAuth) or via LINEAR_API_KEY
↓
Comment / agent-activity appears on the Linear issue.For an assigned issue, Linear opens an agent session instead of a mention notification. LinearPlugin checks isAssignedToAva(issueId) (via Ava's OAuth client, fail-open on API error), and only the assigned sessions get skillHint: linear_agent_respond. Sessions on issues not assigned to Ava are dropped with no response.
Posting as Ava (actor=app OAuth)
Linear distinguishes a personal API key (posts as the human who minted it) from an actor=app OAuth grant (posts as the application — i.e. "Ava"). When Ava's OAuth token is configured, LinearPlugin instantiates an _avaClient (built via getLinearAvaTokenManager) and uses it for two jobs:
- Posting as Ava — agent activities and plain comments go out under Ava's app identity, not a human's. This is the preferred outbound path.
- Gating responses —
isAssignedToAva(issueId)resolves the issue's assignee against Ava's app identity, which is how the assigned-session gate above decides whether to respond.
LINEAR_API_KEY is the fallback: if no Ava OAuth token is present, outbound mutations fall back to the personal key. With neither set, outbound Linear mutations are disabled (inbound webhooks still work).
The one-time browser authorize that captures Ava's actor=app refresh token is driven through the admin /api/linear/oauth/* routes (auth = WORKSTACEAN_API_KEY as ?apiKey=).
Linear → proto code-execution bridge
LinearProtoBridgePlugin (lib/plugins/linear-proto-bridge.ts) dispatches Linear issues tagged with a trigger label to the in-process proto agent as a one-shot code.execute. Pure bus contract — no direct dependency on LinearPlugin or the proto runtime.
Configuration
The trigger label defaults to proto-task. Override it with the LINEAR_PROTO_BRIDGE_LABEL env var. There is no config file and no per-team mapping — proto is a single fleet-wide agent.
Flow
User creates / labels a Linear issue with "proto-task"
↓
LinearPlugin publishes: message.inbound.linear.issue.created
↓ { issueId, title, labels: ["proto-task"], ... }
↓
LinearProtoBridgePlugin matches the label and publishes:
agent.skill.request {
skill: "code.execute",
targets: ["proto"],
content: "Execute this scoped coding/research task ...",
meta: { sourceLinearIssueId, sourceLinearIdentifier, triggerLabel, via: "linear-proto-bridge" },
reply: { topic: "linear.reply.{issueId}" }
}
↓
SkillDispatcherPlugin routes to the in-process proto DeepAgent
↓
proto's result is published on linear.reply.{issueId}
↓
LinearPlugin outbound subscriber posts it as a Linear comment.Issues without the label are dropped at the bridge (the linear_agent_respond mention/assignment path above is independent). The bridge holds no state — reply.topic is the entire close-the-loop contract; code.execute is one-shot.