Skip to content

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

FieldValue
URLhttps://<your-public-host>/webhooks/linear (port 8084 by default)
Resource typesIssue, Comment, Project (pick whichever you want the bus to see)
Signing secretGenerated 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:

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: quinn

channelId 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

VariableRequiredDescription
LINEAR_API_KEYFor outboundPersonal API key for GraphQL mutations
LINEAR_WEBHOOK_SECRETFor inboundHMAC-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_PORTNoWebhook 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)

TopicFires onKey payload fields
message.inbound.linear.issue.createdIssue createdissueId, identifier, title, description, priority, state, teamKey, projectId, assigneeId, labels, url
message.inbound.linear.issue.updatedIssue updatedsame shape as created
message.inbound.linear.issue.removedIssue deletedsame shape
message.inbound.linear.comment.createdComment createdcommentId, issueId, issueIdentifier, body, userId, teamKey, url
message.inbound.linear.comment.updatedComment editedsame shape
message.inbound.linear.project.createdProject createdprojectId, name, description, state, creatorId
message.inbound.linear.project.updatedProject updatedsame 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)

TopicPayloadEffect
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:

  1. 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.
  2. Gating responsesisAssignedToAva(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 statereply.topic is the entire close-the-loop contract; code.execute is one-shot.

protoWorkstacean — a switchboard, not an agent.