Skip to content

feat(agentex): Linear gateway — invoke agents from Linear - #399

Merged
michael-chou359 merged 3 commits into
mainfrom
mc/linear-gateway
Aug 10, 2026
Merged

feat(agentex): Linear gateway — invoke agents from Linear#399
michael-chou359 merged 3 commits into
mainfrom
mc/linear-gateway

Conversation

@michael-chou359

@michael-chou359 michael-chou359 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What

A platform-side ingress that fronts a Linear agent app and routes each @mention / assignment to the resolved agent runtime — the Linear analog of the Slack gateway (#388 / #392 / #394).

How it works

  • POST /linear/events — verifies the Linear-Signature (HMAC-SHA256 over the raw body) + a webhookTimestamp freshness guard, dedups on the Linear-Delivery id, acks fast (Linear's ~10s window), and runs the turn in the background. Auth-whitelisted like /slack — Linear can't present an SGP principal, so the signature is the auth, verified in the use case.
  • Normalize AgentSessionEvent (created / prompted) → the same selector-cascade + task/create-or-event/send dispatch as the Slack gateway, keyed on the agent session (task_metadata.channel = "linear").
  • Reply via agentActivityCreate — a thought immediately (a session is marked unresponsive without an activity within ~10s), then a terminal response / error. The Linear API token is minted via the OAuth client_credentials grant (inherently app-actor) and re-minted reactively on a 401 — no perishable token is stored, only the static client id/secret in env.
  • Removes a duplicate slack.router registration in app.py.

Identity

Runs as a dedicated bot service account (its own SGP identity), not a proxy for the invoking Linear user — consistent with the Slack gateway's identity model. Its comments/activities render as the app (actor=app).

Config (env / k8s-secret)

LINEAR_CLIENT_ID, LINEAR_CLIENT_SECRET, LINEAR_WEBHOOK_SIGNING_SECRET, LINEAR_GATEWAY_ACTING_BOT_API_KEY, LINEAR_GATEWAY_ACCOUNT_ID.

Testing

  • 20 unit tests: signature verify, normalize (created / prompted / ignored), event control-flow (dev-skip / drop / dedup / ack), acting-identity fail-closed, dispatch metadata, and agentActivityCreate token-mint + 401 re-mint. All pass, ruff clean.
  • Signed ingress round-trip verified over HTTP; client_credentials token mint verified against the live Linear API (app-actor confirmed via viewer).
  • The full agent round-trip (posting an activity to a real session) validates post-deploy with a real @mention — a fabricated session can't be tested locally.

🤖 Generated with Claude Code

Greptile Summary

The PR adds a Linear webhook gateway that authenticates and deduplicates session events, dispatches them to Agentex runtimes under a dedicated bot identity, and returns activities through Linear’s API.

  • Registers and documents POST /linear/events.
  • Normalizes Linear session events and creates or resumes session-bound Agentex tasks.
  • Adds OAuth token minting, reactive refresh, activity delivery, and unit coverage.
  • Removes the duplicate Slack router registration.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
agentex/src/domain/use_cases/linear_gateway_use_case.py Implements signature verification, event normalization, deduplication, bot identity resolution, runtime dispatch, reply collection, and Linear activity delivery; the displayed prior findings are fixed or explicitly accepted.
agentex/src/api/routes/linear.py Adds the thin /linear/events ingress that preserves the raw request body for signature verification.
agentex/src/api/middleware_utils.py Whitelists the Linear route from principal authentication so webhook signature verification serves as its ingress authentication.
agentex/src/api/app.py Registers the Linear router while removing the duplicate Slack router registration.
agentex/tests/unit/use_cases/test_linear_gateway_use_case.py Covers authentication, normalization, control flow, acting identity, dispatch metadata, token minting, and token refresh behavior.
agentex/openapi.yaml Documents the new Linear webhook endpoint and successful response schema.

Sequence Diagram

sequenceDiagram
    participant L as Linear
    participant R as POST /linear/events
    participant G as LinearGatewayUseCase
    participant D as Dedup Store
    participant A as Agentex Runtime
    participant API as Linear API
    L->>R: Signed AgentSessionEvent
    R->>G: Raw body, headers, payload
    G->>G: Verify HMAC and timestamp
    G->>D: Reserve delivery ID
    G-->>L: 200 acknowledgment
    G->>API: Create thought activity
    G->>A: Create/resume task and send event
    A-->>G: Agent response
    G->>API: Create response or error activity
Loading

Reviews (3): Last reviewed commit: "Merge branch 'main' into mc/linear-gatew..." | Re-trigger Greptile

A platform-side ingress that fronts a Linear agent app and routes each
@mention / assignment to the resolved agent runtime — the Linear analog of the
Slack gateway.

- POST /linear/events: verify Linear-Signature (HMAC-SHA256 over the raw body)
  + webhookTimestamp freshness, dedup on the Linear-Delivery id, fast-ack +
  background dispatch. Auth-whitelisted (Linear can't present an SGP principal;
  the signature is the auth, verified in the use case).
- Normalize AgentSessionEvent (created/prompted) and run the same selector
  cascade + task/create-or-event/send dispatch as the Slack gateway, keyed on
  the agent session (task_metadata.channel="linear").
- Reply via agentActivityCreate: a thought immediately (Linear's ~10s
  responsiveness window), then a terminal response/error. The Linear API token
  is minted via the client_credentials grant (inherently app-actor) and
  re-minted reactively on a 401 — no perishable token stored.
- Remove a duplicate slack.router registration in app.py.

Runs as a dedicated bot service account (its own identity), not a proxy for the
invoking user — consistent with the Slack gateway's identity model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@michael-chou359
michael-chou359 requested a review from a team as a code owner August 10, 2026 06:26
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

✱ Stainless preview builds

This PR will update the agentex-sdk SDKs with the following commit messages.

openapi

feat(api): add linear events webhook endpoint

python

chore(internal): regenerate SDK with no functional changes

typescript

chore(internal): regenerate SDK with no functional changes
agentex-sdk-openapi studio · code

Your SDK build had at least one "note" diagnostic.
generate ✅

⚠️ agentex-sdk-typescript studio · code

Your SDK build had at least one "warning" diagnostic.
generate ⚠️build ⏭️lint ⏭️test ✅

⚠️ agentex-sdk-python studio · code

Your SDK build had at least one "warning" diagnostic.
generate ⚠️build ⏭️lint ⏭️test ✅


This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-08-10 16:42:36 UTC

Comment thread agentex/src/domain/use_cases/linear_gateway_use_case.py
Comment on lines +475 to +481
seen = await self._seen_message_ids(acp.task_message_service, task.id)
await acp.handle_rpc_request(
method=AgentRPCMethod.EVENT_SEND,
params=SendEventRequestEntity(task_name=task_name, content=content),
agent_id=agent.id,
)
return await self._collect_reply(acp.task_message_service, task.id, seen)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Concurrent Turns Mix Their Replies

If two distinct deliveries for one Linear session overlap, both turns snapshot and poll the same task without event-level correlation, causing either poller to collect the other prompt's agent messages and emit swapped, combined, or duplicated responses.

Prompt To Fix With AI
This is a comment left during a code review.
Path: agentex/src/domain/use_cases/linear_gateway_use_case.py
Line: 475-481

Comment:
**Concurrent Turns Mix Their Replies**

If two distinct deliveries for one Linear session overlap, both turns snapshot and poll the same task without event-level correlation, causing either poller to collect the other prompt's agent messages and emit swapped, combined, or duplicated responses.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid but inherited — documented in 88283a5. Reply attribution is by message-id snapshot, the same interim design the Slack gateway ships with (see _collect_reply), so two prompts racing on the same session can interleave. Linear sessions are sequential in practice (prompt → wait → prompt), so the window is narrow. A real fix is event-correlated streaming, which is a cross-gateway change tracked as the shared follow-up — not something to diverge on in this PR. Added a note to the _collect_reply docstring.

Comment thread agentex/src/domain/use_cases/linear_gateway_use_case.py
Comment thread agentex/src/domain/use_cases/linear_gateway_use_case.py
michael-chou359 and others added 2 commits August 9, 2026 23:34
- verify_signature FAILS CLOSED on an empty signing secret: an empty HMAC key is
  publicly known and forgeable, so an unconfigured LINEAR_WEBHOOK_SIGNING_SECRET
  must reject every delivery, not authenticate all of them. (P1 security)
- _emit no longer logs the activity body when the token is missing — a response's
  body is agent output that shouldn't reach the logging pipeline; log the fact +
  length at WARNING (a dropped activity means LINEAR_CLIENT_ID/SECRET is unset).
  (P1 security)
- Document the two intended, Slack-inherited behaviors flagged: a session is bound
  to its first-turn target (a follow-up's leading token is not a re-route), and
  reply collection is interim message-id snapshotting (concurrent same-session
  prompts can interleave; event-correlated streaming is the shared follow-up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@michael-chou359
michael-chou359 merged commit 4718eee into main Aug 10, 2026
6 checks passed
@michael-chou359
michael-chou359 deleted the mc/linear-gateway branch August 10, 2026 16:40
@michael-chou359
michael-chou359 restored the mc/linear-gateway branch August 10, 2026 16:43
michael-chou359 added a commit that referenced this pull request Aug 10, 2026
…401)

Re-lands the Linear gateway from #399, which was merged (`4718eee`) then
reverted by #400 (`bcf5a79`).

## Why it was reverted — and why that wasn't a code problem
#399's revert was triggered by the **"Stainless build"** check, which
failed in its *merge* step with:

> `404 One or both branches not found: main, preview/mc/linear-gateway`
— "This is a bug. Please report it at
https://github.com/stainless-api/upload-openapi-spec-action/issues"

That's a **Stainless SDK-generation / CI-infra failure** (the action
couldn't find a preview branch and flags it as its own bug), not a
defect in the gateway. **All 42 code checks on `4718eee` passed** — the
full unit + sync/async integration matrix. So no code change was needed
to re-land; this is the same commit (including the Greptile review
fixes).

If the Stainless build fails again the same way, it's an infra issue to
re-run / raise with the Stainless integration owners, not a blocker on
this code.

## What it is (unchanged from #399)
Platform-side ingress fronting a Linear agent app; `POST /linear/events`
verifies `Linear-Signature` + `webhookTimestamp`, dedups on
`Linear-Delivery`, normalizes `AgentSessionEvent` (created/prompted),
runs the Slack-gateway selector-cascade dispatch
(`task_metadata.channel="linear"`), and replies via
`agentActivityCreate` (thought → terminal response/error) using a
`client_credentials` app-actor token (re-minted on 401). Runs as a
dedicated bot service account. Includes the #399 review fixes:
fail-closed on empty signing secret, and no response-body in logs on
token failure.

## Testing
21 unit tests pass, ruff clean. Signed ingress + client_credentials
token mint validated against live Linear earlier.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- greptile_comment -->

<h3>Greptile Summary</h3>

The PR adds a signed Linear webhook gateway that deduplicates
deliveries, dispatches Linear agent-session turns through AgentEx, and
returns activities using an app-actor OAuth token.
- Registers and documents `POST /linear/events`.
- Adds signature freshness checks, delivery deduplication, target
selection, bot identity propagation, task dispatch, reply polling, and
Linear activity delivery.
- Adds unit coverage for authentication, normalization, dispatch,
identity handling, and token refresh.

<details><summary><h3>Confidence Score: 4/5</h3></summary>

The follow-up selector handling should be fixed before merging because
it can silently alter user prompts in existing Linear sessions.

Prompted events pass through the same selector-resolution path as
initial events, so a leading word matching an agent or configuration is
removed despite the session contract requiring follow-up content to
remain intact.

**Files Needing Attention:**
agentex/src/domain/use_cases/linear_gateway_use_case.py
</details>

<details><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| agentex/src/domain/use_cases/linear_gateway_use_case.py | Implements
the complete Linear ingress and dispatch lifecycle, but applies
first-turn selector stripping to prompted follow-ups. |
| agentex/src/api/routes/linear.py | Adds the Linear webhook route and
preserves the raw request body required for HMAC verification. |
| agentex/src/api/middleware_utils.py | Whitelists Linear ingress so the
gateway's webhook-signature authentication can run. |
| agentex/src/api/app.py | Registers the new Linear router while
removing a duplicate Slack router registration. |
| agentex/openapi.yaml | Documents the new Linear webhook endpoint and
its successful response. |
| agentex/tests/unit/use_cases/test_linear_gateway_use_case.py | Covers
core gateway behavior but does not exercise selector-like first words in
prompted follow-ups. |

</details>

<details><summary><h3>Sequence Diagram</h3></summary>

```mermaid
sequenceDiagram
    participant L as Linear
    participant API as POST /linear/events
    participant G as LinearGatewayUseCase
    participant R as Redis
    participant ACP as AgentEx ACP
    participant LA as Linear API
    L->>API: Signed AgentSessionEvent
    API->>G: Raw body, headers, payload
    G->>G: Verify signature and timestamp
    G->>R: SET delivery ID NX with TTL
    G-->>L: "200 {ok: true}"
    G->>LA: Emit thought activity
    G->>ACP: Resolve target and create/resume task
    ACP-->>G: Agent reply
    G->>LA: Emit terminal response or error
```
</details>

<a
href="https://app.greptile.com/api/ide/cursor?prompt=%23%23%23%20Issue%201%0Aagentex%2Fsrc%2Fdomain%2Fuse_cases%2Flinear_gateway_use_case.py%3A329%0A**Follow-up%20selector%20strips%20content**%0A%0AWhen%20a%20prompted%20follow-up%20starts%20with%20a%20registered%20agent%20or%20configuration%20name%2C%20%60_resolve_target%60%20treats%20that%20word%20as%20a%20selector%20and%20removes%20it%20before%20dispatching%20to%20the%20existing%20session%20task%2C%20causing%20the%20agent%20to%20receive%20a%20truncated%20user%20instruction%20even%20though%20selectors%20are%20intended%20to%20apply%20only%20on%20the%20first%20turn.%0A%0A---%0A%0AFor%20each%20issue%20above%2C%20determine%20whether%20it%20is%20valid%20and%20should%20be%20fixed.%20If%20so%2C%20fix%20it%20directly.&pr=401&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursorDark.svg?v=6"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursor.svg?v=6"><img
alt="Fix All in Cursor"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursor.svg?v=6"></picture></a>
<a
href="https://app.greptile.com/ide/claude-code?prompt=%23%23%23%20Issue%201%0Aagentex%2Fsrc%2Fdomain%2Fuse_cases%2Flinear_gateway_use_case.py%3A329%0A**Follow-up%20selector%20strips%20content**%0A%0AWhen%20a%20prompted%20follow-up%20starts%20with%20a%20registered%20agent%20or%20configuration%20name%2C%20%60_resolve_target%60%20treats%20that%20word%20as%20a%20selector%20and%20removes%20it%20before%20dispatching%20to%20the%20existing%20session%20task%2C%20causing%20the%20agent%20to%20receive%20a%20truncated%20user%20instruction%20even%20though%20selectors%20are%20intended%20to%20apply%20only%20on%20the%20first%20turn.%0A%0A---%0A%0AFor%20each%20issue%20above%2C%20determine%20whether%20it%20is%20valid%20and%20should%20be%20fixed.%20If%20so%2C%20fix%20it%20directly.&repo=scaleapi%2Fscale-agentex&pr=401&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaudeDark.svg?v=6"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaude.svg?v=6"><img
alt="Fix All in Claude Code"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaude.svg?v=6"></picture></a>
<a
href="https://app.greptile.com/api/ide/codex?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22scaleapi%2Fscale-agentex%22%20on%20the%20existing%20branch%20%22mc%2Flinear-gateway-reland%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22mc%2Flinear-gateway-reland%22.%0A%0A%23%23%23%20Issue%201%0Aagentex%2Fsrc%2Fdomain%2Fuse_cases%2Flinear_gateway_use_case.py%3A329%0A**Follow-up%20selector%20strips%20content**%0A%0AWhen%20a%20prompted%20follow-up%20starts%20with%20a%20registered%20agent%20or%20configuration%20name%2C%20%60_resolve_target%60%20treats%20that%20word%20as%20a%20selector%20and%20removes%20it%20before%20dispatching%20to%20the%20existing%20session%20task%2C%20causing%20the%20agent%20to%20receive%20a%20truncated%20user%20instruction%20even%20though%20selectors%20are%20intended%20to%20apply%20only%20on%20the%20first%20turn.%0A%0A---%0A%0AFor%20each%20issue%20above%2C%20determine%20whether%20it%20is%20valid%20and%20should%20be%20fixed.%20If%20so%2C%20fix%20it%20directly.&repo=scaleapi%2Fscale-agentex&pr=401&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=6"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"><img
alt="Fix All in Codex"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"></picture></a>

<details><summary>Prompt To Fix All With AI</summary>

`````markdown
### Issue 1
agentex/src/domain/use_cases/linear_gateway_use_case.py:329
**Follow-up selector strips content**

When a prompted follow-up starts with a registered agent or configuration name, `_resolve_target` treats that word as a selector and removes it before dispatching to the existing session task, causing the agent to receive a truncated user instruction even though selectors are intended to apply only on the first turn.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
`````

</details>

<sub>Reviews (1): Last reviewed commit: ["feat(agentex): Linear gateway
— invoke
a..."](e0c6d4a)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=51876870)</sub>

> Greptile also left **1 inline comment** on this PR.

<!-- /greptile_comment -->

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant