A label-tagged Linear issue becomes a code.execute dispatch to the in-process proto agent. The bridge bypasses RouterPlugin's keyword/channel resolution and uses a reply.topic contract to round-trip the agent's response back to the Linear issue as a comment, with no bridge-side state.
What & why
RouterPlugin resolves messages via channels + skillHint — neither concept fits a Linear label gate (which inspects the issue payload itself). One bridge fills the gap:
linear-proto-bridge(lib/plugins/linear-proto-bridge.ts) — issues with theproto-tasklabel dispatchcode.executeto the in-processprotoagent. Single global label gate; override viaLINEAR_PROTO_BRIDGE_LABELenv. No per-team mapping —protois one fleet-wide agent.
How this differs from the mention/assignment path
A Linear @mention or assigned-issue session is not a bridge concern. LinearPlugin stamps skillHint: linear_agent_respond on those events and RouterPlugin dispatches them to Ava. Linear inbound is otherwise skillHint-only: an un-hinted Linear event is never keyword-matched, so it drops. The proto-task label bridge is the one place a Linear event turns into a dispatch without a skillHint — it builds the agent.skill.request itself.
ASCII spine
Linear webhook
│
▼
┌──────────────────────────┐
│ LinearPlugin (inbound) │ validates, dedupes
└──────────────┬───────────┘
│
▼
┌──────────────────────────┐
│ message.inbound.linear. │
│ issue.created │
└──────────────┬───────────┘
│
▼
┌────────────────┐
│ proto bridge │ subscribes to issue.created
│ │
│ label gate │ default "proto-task"
│ (env- │ override LINEAR_PROTO_BRIDGE_LABEL
│ overridable) │
└───────┬────────┘
│
▼
┌──────────────────────────┐
│ agent.skill.request │ skill=code.execute, targets=[proto]
│ │ reply.topic = linear.reply.{issueId}
└──────────────┬───────────┘
▼
SkillDispatcher (→ [flow-inbound-message](flow-inbound-message.md))
│
▼
┌──────────────────────────┐
│ linear.reply.{issueId} │ ← executor publishes here
└──────────────┬───────────┘
│ LinearPlugin subscribes (wildcard linear.reply.#)
▼
Linear API
createComment(issueId, content)Sequence
sequenceDiagram
autonumber
participant Ext as Linear (external)
participant LP as LinearPlugin
participant Bus as Bus
participant B as linear-proto-bridge
participant SD as SkillDispatcher
participant E as proto (DeepAgentExecutor)
Ext->>LP: webhook (Issue.Create)
LP->>LP: validate + dedupe
LP->>Bus: message.inbound.linear.issue.created
Bus->>B: deliver
rect rgb(240, 230, 220)
Note over B: label gate (proto-task)
alt label matches
B->>B: build content + meta
B->>Bus: agent.skill.request<br/>(code.execute@proto, reply.topic = linear.reply.{issueId})
else label miss
Note over B: silent drop
end
end
Bus->>SD: deliver
SD->>E: execute(req)
E-->>Bus: linear.reply.{issueId}<br/>(payload.content = result)
Bus->>LP: deliver (subscriber: linear.reply.#)
LP->>LP: extract issueId from topic
LP->>Ext: createComment(issueId, content)
LP->>Bus: linear.reply.result.{correlationId}<br/>(record API result)Bus topic table
| Topic | Published by | Subscribed by | File:line |
|---|---|---|---|
message.inbound.linear.issue.created | LinearPlugin (webhook) | RouterPlugin (skillHint-only), linear-proto-bridge | lib/plugins/linear.ts |
agent.skill.request (code.execute, proto) | linear-proto-bridge | SkillDispatcher | lib/plugins/linear-proto-bridge.ts:105 |
linear.reply.{issueId} | SkillDispatcher / executor (writes payload.content) | LinearPlugin outbound subscriber | lib/plugins/linear.ts |
linear.reply.result.{correlationId} | LinearPlugin (after API call) | telemetry | lib/plugins/linear.ts |
Gate logic
Single global label, env-overridable:
default: proto-task
override: LINEAR_PROTO_BRIDGE_LABEL=<custom>Gate sequence (line 81–140):
- Drop if
issueId || titlemissing (line 83) - Check
issue.labels.includes(this.triggerLabel)— drop if absent (line 86–92) - Build
agent.skill.requestwith skillcode.execute, targets["proto"], reply.topiclinear.reply.${issueId}(line 105)
Reply round-trip
The bridge sets reply.topic = linear.reply.{issueId} on dispatch. SkillDispatcher publishes the executor's response to that topic. LinearPlugin subscribes to linear.reply.# (excluding linear.reply.result.* to avoid a feedback loop) and:
- Extracts
issueIdfrom the topic - Posts the comment (as Ava via actor=app OAuth when wired, else via
LINEAR_API_KEY) - Publishes
linear.reply.result.{correlationId}with the API result for audit
The bridge holds no state for round-trip — reply.topic is the entire close-the-loop contract, and code.execute is one-shot: dispatch, run to completion, reply.
Failure modes & gotchas
- Silent-drop on non-matching label — by design. The mention/assignment path (
linear_agent_respondto Ava) is independent and handles its own events. - No "task completed" close-the-loop — when the agent's work eventually merges, no automatic comment lands on the originating Linear issue. The bridge only handles the first dispatch reply.
correlationIdconvention —linear-proto-${issueId}. Not used for reply routing (that'sreply.topic); used for tracing andlinear.reply.result.*correlation.
Related
- integrations/linear — full Linear plugin contract, the
linear_agent_respondmention/assignment gate, and actor=app OAuth posting. - flow-inbound-message — what happens after the bridge publishes
agent.skill.request. - flow-pr-review — the analogous label-gated dispatch for GitHub PRs (no separate bridge, lives inside GitHubPlugin).
- flow-hitl — what happens if the dispatched agent escalates.