Part 2 of RFC #4492 ("converse capability + Linear agents as first-class workspace members"). With this integration a hive joins a Linear workspace as an agent: it can be assigned or @-mentioned on issues, it acknowledges each agent session within Linear's 10-second budget, kicks a configured hive agent to do the work, and narrates completion back into the session as agent activities.
Part 1 (#4515) added the
converse capability; the api.linear.app proxy enforcement (component F)
merged in #4522.
Linear hive spoke
────── ──────────
issue delegated to app ──webhook──▶ POST /api/linear/webhook (public; HMAC = credential)
│ verify Linear-Signature + ±60s replay window
│ respond 200 immediately (5s budget)
▼
Responder (pkg/linearagent)
│ 1. post `thought` activity ◀── SYNCHRONOUS, ≤8s deadline
│ (never waits on the governor loop or tmux)
│ 2. resolve session agent (config, no I/O)
│ 3. SendKick(agent, issue context)
│ 4. post `action` activity ("Delegated")
▼
agent works (tmux CLI session)
│
kick log archived (next rotation point, #4296)
▼
kick observer → `response` activity, session finished
The <10s acknowledgement never depends on the governor's eval_interval_s
(300s) or on agent startup: the thought is posted synchronously on webhook
receipt, before the kick is even attempted. A kick refusal (paused agent,
unknown agent) is reported into the session as an error activity.
Completion detection is intentionally coarse: a run "ends" at the next kick-log rotation point (a newer kick, an agent restart, shutdown). There is no mid-run streaming of agent output into Linear yet.
-
Create a Linear OAuth application (Linear → Settings → API → Applications) with:
- Callback URL:
https://<your-hive>/linear/callback - Webhooks enabled, URL
https://<your-hive>/api/linear/webhook, with the Agent session events category checked. Copy the signing secret.
- Callback URL:
-
Set the environment variables on the hive (see env-vars.md):
LINEAR_CLIENT_ID,LINEAR_CLIENT_SECRET,LINEAR_WEBHOOK_SECRET. -
Tell the hive its public origin when the dashboard is not reached at
https://<your-hive>directly. Theredirect_urithe hive sends Linear is built from, in order:dashboard.public_url, thenhub.dashboard_url(hub-hosted spokes only), then the request'sX-Forwarded-Proto/X-Forwarded-Host/Host. Set the first one on a standalone (hub.enabled: false) hive whose dashboard is private but whose/linear/callbackis published on another hostname, or that sits behind an ingress that rewrites theHostheader (Traefik with a fixed upstreamHost, a Cloudflare Tunnel "HTTP Host Header") — otherwise the install request and the callback request derive different origins and Linear refuses the code exchange withredirect_uri is invalid:dashboard: public_url: https://hive.example.com # origin only: no path or query
It must be an absolute
http(s)://origin; a path, query, fragment or credentials fail config load with a clear error, and a trailing slash is trimmed. It is honored from the seed config (hive.yaml). Do not reach forhub.dashboard_urlon a hub-less hive — it is the hub's notion of the spoke's dashboard link and stays only as the fallback for hub-hosted spokes. -
Connect the workspace: as an owner,
POST /api/linear/agent/installreturns anauthorize_urland theredirect_uriit was built with — thatredirect_uriis the exact value the Linear app's Callback URL must match, so check it here rather than decoding the authorize URL. Open theauthorize_urlin a browser and approve the app for the workspace (the flow usesactor=appand theapp:assignable,app:mentionablescopes, so the app becomes an assignable, mentionable workspace member). Linear redirects back to/linear/callback, and the hive stores the workspace grant + its per-workspace app user id atLINEAR_AGENT_STORE(default/data/linear-agent.json). -
Pick the session agent when the hive runs more than one agent — from the dashboard (Governor → Work Source → Linear → Session agent) or in
hive.yaml:governor: work_source: linear: session_agent: scanner # agent that takes Linear sessions
With exactly one configured agent this is implicit. The dashboard rejects (400) a name that matches no configured agent.
-
Optional — enumerate only delegated/assigned issues: when the Linear work source is active, Assigned only in the dashboard (or
assigned_only: trueinhive.yaml) narrows backlog enumeration to issues assigned or delegated to the app user (Linear setsdelegate, notassignee, when an issue is handed to an agent):governor: work_source: type: linear linear: api_key: ${LINEAR_API_KEY} assigned_only: true
This fails closed:
assigned_onlywithout a connected Linear agent is a startup error, never "enumerate everything" — and the dashboard refuses to save it (400) until step 4 has been completed. -
Map teams to repos. The whole
work_source.linearblock — API key, hold labels, session agent, assigned-only, and the team list with each team'srepo,states,cyclesand per-project repo overrides — is settable from the dashboard's Work Source tab, backed byGET/PUT /api/config/governor/work-source(owner-only). Setstates(e.g.Todo, In Progress) on each team: without it every open issue in the team counts toward the governor's backlog and a large backlog will put the hive into SURGE.GETnever returns the API key value, onlyapi_key_set; aPUTthat omitsapi_keykeeps the stored one. Theteamslist is replaced when present and untouched when absent:{ "type": "linear", "linear": { "session_agent": "scanner", "assigned_only": false, "hold_labels": ["hold"], "teams": [ {"key": "ENG", "repo": "my-org/app", "states": ["Todo", "In Progress"], "cycles": "current", "projects": [{"name": "Billing", "repo": "my-org/billing"}]} ] } }
With type: linear the GitHub App does not need the Issues read
permission: a failed GitHub issue enumeration is logged as a warning and the
cycle continues, with the Linear backlog supplying issues and GitHub supplying
PRs when it can.
GET /api/linear/agent/status (owner) reports configuration, the connected
workspace, the resolved session agent, and recent sessions.
POST /api/linear/agent/disconnect (owner) forgets the stored grant (revoke
the app itself from Linear's settings).
With the pieces above a hive could read Linear and acknowledge sessions,
but an agent still had no way to do the tracker half of its policy —
file an issue, comment, cite the issue from a PR — against Linear. The
policy templates are written for GitHub Issues (gh issue create,
Fixes #N, the hold label), and the proxy's mutation allowlist was gated
but nothing reachable: no agent held a Linear credential. Parity is built
the same way the GitHub path is, from the config that already exists:
| GitHub Issues | Linear | Mechanism |
|---|---|---|
App installation token, tier-scoped, pushed as GITHUB_TOKEN to push-capable agents and refreshed hourly |
The connected app's OAuth token pushed as LINEAR_ACCESS_TOKEN (Bearer) to ISSUES_ONLY+ agents; falls back to work_source.linear.api_key as LINEAR_API_KEY; re-pushed on the same hourly refresh tick. The copy in the agent's environment is not what Linear sees: the OAuth token rotates (~24h) and a running CLI keeps the environment it was forked with, so the egress proxy — which already terminates every agent request to api.linear.app for the tier gate — replaces the Authorization header of every ISSUES_ONLY+ request with the hive's current credential. A stale, absent, or placeholder token in the agent's shell all work. |
agent.Manager.SetLinearCredentialResolver and proxy.GitHubProxy.SetLinearCredentialResolver, both wired in main.go from the same closure over dashboard.Server.LinearAgentAccessToken |
Advisory agents have GH_TOKEN/GITHUB_TOKEN stripped |
Advisory agents have both Linear variables stripped from the tmux session | ensureTmuxSession |
| Writes authored by the App bot | Writes authored by the Hive app user (the same identity that acknowledges sessions) | actor=app grant |
${GH_AUTH} explains auth; templates give gh issue create and Fixes #N |
A Work Tracker: Linear section rendered from work_source.linear (team → repo map, states, hold labels, assigned_only) and injected into every kick at the same post-resolution seam as held-PR coordination, so customized templates cannot omit it; ${WORK_TRACKER} places it explicitly |
pkg/scheduler/work_tracker.go |
Fixes #N auto-closes on merge |
Linear's GitHub integration: identifier in the branch name or Fixes TEAM-123 in the PR body links the PR, moves the issue to In Progress when it opens and Done when it merges; Part of / Refs / Contributes to are the non-closing forms |
Linear-side, nothing hive-specific |
| REST route table gates writes by tier | GraphQL operation allowlist gates writes by tier (below) | pkg/proxy/linear_rules.go |
Nothing changes for a GitHub-sourced hive: with no Linear credential the resolver injects nothing and the tracker section is empty.
Agents are told not to change the state of PR-driven issues by hand —
Linear's GitHub integration owns that transition, exactly as GitHub's
Fixes keyword owns issue closure. Make sure the integration is enabled
for the workspace and the repos in work_source.linear.teams[].repo are
connected to it.
A delegated issue reaches the hive twice: the webhook opens an agent
session (kicked immediately to the session agent), and — with
assigned_only: true — the same issue is enumerated into the governor's
backlog on the next sweep. Kicks never interrupt a running agent
(SendKick waits for the CLI's input prompt), so the risk is a re-hand:
the governor kicking the same issue again the moment the session's run
ends, or a second agent in the lane taking it in parallel.
The session tracker is therefore the in-flight ledger. While a session is
working, the scheduler withholds its issue from every governor kick's
${ISSUE_LIST} and IssueRefs, and says so in an In Flight note
appended at the same seam as the tracker section (${IN_FLIGHT} places it
explicitly). The hold releases when the session finishes — its kick log
archives — or fails. GitHub-sourced items are never session-held.
work_source.linear.session_agent when set; otherwise the sole configured
agent; otherwise the sole enabled agent whose ACMM mode allows tracker
writes (ISSUES_ONLY and above) — which is what makes the L3 pack (six
agents, quality the only writer) work without extra config. Two or more
writers is ambiguous and the session is acknowledged with an error naming
the setting.
When the hive's hive-open-pr watcher opens a PR for an agent with an
active session, the PR is narrated into the session as an action
activity and attached to the session's external links
(agentSessionUpdate.externalUrls), so the person who delegated the issue
sees where the work landed before the run ends. Linear's GitHub integration
attaches the same PR to the issue on its own; this is the session
surface.
Agent-side calls to api.linear.app go through the ACMM proxy
(pkg/proxy/linear_rules.go, merged in #4522): a deny-by-default GraphQL
mutation allowlist where agentActivityCreate/agentSessionUpdate are
reachable at every tier (they carry the 10-second session invariant),
issueUpdate/commentCreate require ISSUES_ONLY, and anything unknown or
unparseable is denied. The control-plane client in pkg/linearagent (webhook
ack, identity query, token refresh) runs in the hive process itself, not
through the agent proxy.
CI exercises every component against recording fakes; these behaviors depend on Linear's side of the contract and need a manual pass against a real workspace:
- OAuth install: authorize with
actor=appand confirm the app appears as an assignable, mentionable member; callback lands with?linear=connectedand status shows the workspace +viewer_id. - Webhook signatures: real deliveries verify against
LINEAR_WEBHOOK_SECRET(bare hex HMAC inLinear-Signature, nosha256=prefix) and pass the ±60swebhookTimestampwindow. - Ack timing: delegate an issue to the app and confirm the session shows the thought within 10s (the responder's deadline is 8s).
- Prompt field positions:
promptedevents are parsed fromagentActivity.content.bodywith a fallback toagentActivity.body, and issue context frompromptContextat both the top level and underagentSession— confirm real payloads land in one of those. - Delegate filter: with
assigned_only: true, confirm delegated issues (Linear setsdelegate) are enumerated and unrelated backlog is not. - Token refresh: after ~24h, confirm the stored access token refreshes transparently (30-minute grace window; the old refresh token is kept when the response omits a new one).
- Agent writes: from an ISSUES_ONLY+ agent session, confirm
LINEAR_ACCESS_TOKENis set (and absent in an advisory session), thatLINEAR_API_KEY,LINEAR_CLIENT_SECRETandLINEAR_WEBHOOK_SECRETare absent even though the hive process holds them (a fresh pane must not inherit them from the tmux server's global environment), that anissueCreatelands authored by the Hive app user — an issue created by a person means the agent found the work-source key — and that anissueDeleteis refused by the proxy with a 403 naming the operation. - PR auto-link: open a PR on a branch named
<agent>/team-123-slugwithFixes TEAM-123in the body and confirm Linear attaches it and moves the issue to In Progress, then Done on merge. - Session PR link: with a session
working, have the agent open a PR throughhive-open-prand confirm the session shows an "Opened pull request" activity and the PR under its external links. - In-flight withholding: while a session is
working, trigger a governor kick for the same agent and confirm the delegated issue is absent from its work list and named under "In Flight"; after the run ends, confirm it is handed out again on the next sweep (or has left the enumerated states via the PR).