Bridges Linear agent session webhooks to Hermes runs.
50K+
Bridges Linear agent session webhooks to Hermes runs and posts run progress back as Linear agent activities. Talks directly to a Hermes API server — any deployment that exposes /v1/runs.
docker run --rm -p 8080:8080 cenk1cenk2/hermes-bridge-linear:latest
| Tag | Source |
|---|---|
latest | The latest release. |
v<version> | The release @kilic.dev/hermes-bridge-linear@<version> created by semantic-release. |
Durations are milliseconds unless noted.
| Variable | Default | Description |
|---|---|---|
PORT | 8080 | Port the HTTP server binds to. |
AGENT_NAME | Hermes | Name the agent goes by in its activities, e.g. labrat: the acceptance line and the answer to a user who is not allowed. |
LOG_LEVEL | info | Lowest level logged: error, warn, info, debug or verbose. |
LOG_FORMAT | json | json for one JSON object per line, text for readable local lines. |
REDIS_URL | required | Redis or Valkey holding every queue and all shared state. |
QUEUE_PREFIX | linear-hermes-bridge | Prefix of every Redis key and queue. |
QUEUE_RETENTION_COMPLETED | 3600 | Seconds a completed job is kept. |
QUEUE_RETENTION_FAILED | 86400 | Seconds a failed job is kept. |
QUEUE_TRANSACTION_RETRIES | 10 | Attempts of a Redis transaction that keeps conflicting. |
LINEAR_CLIENT_ID | required | Client id of the Linear app, for the client credentials token. |
LINEAR_CLIENT_SECRET | required | Client secret of the Linear app. |
LINEAR_WEBHOOK_SECRET | required | Signing secret of the Linear webhook. |
LINEAR_SCOPES | read,write,app:assignable,app:mentionable | Scopes of the minted token; a mint with other scopes revokes every app token. |
LINEAR_TOKEN_URL | https://api.linear.app/oauth/token | OAuth token endpoint for the mint. |
LINEAR_ACTIVITY_RATE_LIMIT_MAX | 1 | Activities posted per rate limit window, across replicas. |
LINEAR_ACTIVITY_RATE_LIMIT_DURATION | 1000 | Length of the rate limit window. |
LINEAR_WEBHOOK_TIMEOUT | 4000 | Time a webhook may spend queueing its event before it answers 503. |
LINEAR_WEBHOOK_INSTRUCTIONS | none | Instructions every Linear input carries. |
LINEAR_WEBHOOK_ALLOWED_USERS | Comma-separated Linear user ids allowed to trigger the agent: the session creator on a mention, the prompt author on prompted. Anyone else gets one response and no run. Delegating an issue to the agent is an explicit request and bypasses the list. Empty allows everyone. | |
LINEAR_SESSION_TTL | 86400000 | Lifetime of seen stops, a session's last stop, tool arguments and plan state. |
LINEAR_SESSION_TRUNCATE_PARAMETER | 200 | Characters of a tool argument shown in its action. |
LINEAR_SESSION_TRUNCATE_RESULT | 1000 | Characters of a tool result shown in its action. |
LINEAR_RECOVERY_AGE | 86400000 | Age of the oldest session the boot sweep resumes or closes. |
LINEAR_RECOVERY_GRACE | 60000 | Age a session needs before the boot sweep; Linear retries younger events. |
LINEAR_RECOVERY_PAGE_SIZE | 50 | Sessions fetched per page by the boot sweep. |
LINEAR_RECOVERY_PAGES | 20 | Pages of sessions the boot sweep reads at most. |
HERMES_URL | required | Base URL of the Hermes API server, e.g. https://hermes.example.com/v1. |
HERMES_API_KEY | required | Key sent to the Hermes API server. |
HERMES_INSTRUCTIONS | none | Instructions of a run whose input carries none, that is when LINEAR_WEBHOOK_INSTRUCTIONS is not set. |
HERMES_EVENTS_IDLE_TIMEOUT | 120000 | Silence on a run event stream before it is dropped and the run is polled. |
DRIVER_SEEN_TTL | 86400000 | Lifetime of a seen idempotency key. |
DRIVER_QUEUE_LOCK_TTL | 30000 | Lifetime of a thread lock. |
DRIVER_QUEUE_LOCK_RENEW_INTERVAL | 10000 | Renewal interval of a held thread lock. |
DRIVER_QUEUE_SWEEP_INTERVAL | 1000 | Interval of the sweep for threads with due work. |
DRIVER_BACKOFF_INITIAL | 5000 | First retry delay while a run create gets 429, a 5xx or no connection. |
DRIVER_BACKOFF_MAX | 60000 | Largest retry delay. |
DRIVER_POLICY_BATCH | true | Join the inputs queued behind a run into one run. |
DRIVER_POLICY_STEER | true | Steer a follow-up into the running run instead of queueing it. |
DRIVER_POLICY_RESUBMIT_MAX | 2 | New runs in the same Hermes session for a run that ends interrupted. |
DRIVER_POLICY_BACKOFF_WINDOW | 600000 | Time Hermes may stay busy or unavailable before the thread fails. |
DRIVER_POLICY_APPROVAL | deny-stop | deny-stop stops a run after denying its approval request, deny-continue lets it go on. |
DRIVER_FOLLOW_LEASE_TTL | 30000 | Lifetime of a run lease; another replica takes the run over once it expires. |
DRIVER_FOLLOW_LEASE_RENEW_INTERVAL | 10000 | Renewal interval of a held run lease. |
DRIVER_FOLLOW_SWEEP_INTERVAL | 5000 | Interval of the sweep for runs nobody follows. |
DRIVER_FOLLOW_POLL_INTERVAL | 5000 | Interval between polls of a run without a stream. |
DRIVER_FOLLOW_RETENTION | 86400000 | Time a finished run's record is kept. |
DRIVER_HEARTBEAT_IDLE | 1200000 | Silence in a thread before it reports idle. |
DRIVER_HEARTBEAT_SWEEP_INTERVAL | 60000 | Interval of the idle sweep. |
SLACK_ENABLED | false | Post a complete answer in one colored legacy Slack attachment for every run that ends with an answer. |
SLACK_TOKEN | required when enabled | Bot token of the Slack app (chat:write scope). |
SLACK_CHANNEL | required when enabled | Channel id the messages go to; the bot must be a member of it. |
SLACK_TTL | 86400000 | Lifetime of the posting claim that deduplicates each run across replicas. |
SLACK_CLIENT_TIMEOUT | 10000 | Timeout of one Slack API call. |
SLACK_CLIENT_RETRIES | 3 | Retries of a Slack API call that fails or is rate limited. |
BOARD_ENABLED | false | Deliver thread state, source metadata and independent board/post reporting flags to the agent runs receiver; per-thread flags do not disable transport. |
BOARD_URL | required when enabled | Endpoint the states are posted to, e.g. https://webhooks.kilic.dev/v1/hooks/hermes. |
BOARD_TIMEOUT | 10000 | Timeout of one delivery POST or reconciliation status query. |
BOARD_RETRIES | 3 | Retries of a post that fails or answers other than 2xx. |
BOARD_REFRESH_INTERVAL | 60000 | Minimum interval between immediate working deliveries from real activity, shared across replicas; not the maintenance interval. |
BOARD_RECONCILE_INTERVAL | 3600000 | Startup and hourly maintenance replay retained projections and repair known active runs; independent of event delivery and receiver rendering. |
BOARD_TTL | 86400000 | Source lifetime of retained board projections and deduplication. Replays and status queries do not renew it. |
With SLACK_ENABLED, a run that completes with an answer posts one colored legacy attachment to SLACK_CHANNEL: a green bar, the title ANSWERED | <identifier> <title> | <agent> on Linear first, the complete quoted answer below it, then an Updated <time> UTC footer and a link button to the agent session on the issue. Paragraph breaks remain single bare > blank quote lines. Markdown headings become bold lines and Markdown links become Slack links; code spans and fenced code stay as written. A run that fails, is stopped, has a command denied or completes without output posts nothing, and each run is posted once across replicas. A Slack failure is logged and never touches the agent activities. The button is a plain link, so the Slack app needs no interactivity request URL.
Answers are not artificially clipped or split by character count or paragraph; Slack controls wrapping and collapse. If the complete attachment exceeds Slack's hard message limit, it explicitly reports that the answer could not be displayed in full and keeps the source buttons; no partial answer is posted. Rendering and posting failures, including Slack truncation warnings, are logged. No extra Slack scope is required.
Answer cards are post-only: no reaction polling, user lookups or manual-resolution edits run at startup or afterward. The bot needs only chat:write and channel membership for these posts. Posting claims remain in Redis under QUEUE_PREFIX for SLACK_TTL; replay after that claim expires can post again. This is not transactional exactly-once delivery across Slack and Redis.
Historical MANUALLY RESOLVED messages are not edited or deleted. Old card records, reaction indexes and resolution locks can remain inert in Redis; no data cleanup is required. Board reporting, its independent reporting controls and the run lifecycle are unchanged.
Reports include optional reporting: { board: boolean, post: boolean }: the receiver filters board visibility and agent announcement posts independently. Missing reporting or either missing flag means true. Both-disabled reports are still retained and delivered so the receiver can clear stale visibility; flags never suppress execution, results, errors or SSE. The resolved policy survives activity, terminal events, retries, outbox delivery and hourly reconciliation without renewing source expiry. Direct Linear webhook threads default to { board: true, post: true }. This policy does not disable platform replies or the separate Slack answer cards.
With BOARD_ENABLED, every thread posts its state to BOARD_URL as JSON {"key","platform","sessionId","state","title","link","links","error","runId","at","sourceActivityAt","reporting"}, next to the agent activities. running means WORKING while a turn is queued or executing; done means IDLE immediately after completion, awaiting another message. Failures, stops and denied commands remain distinct as failed, stopped and approval_denied. Real tool, message, todo and thought activity refreshes running at most once per BOARD_REFRESH_INTERVAL across replicas; silence never changes an active turn to idle, and synthetic idle sweeps send no board updates. Working and completed entries expire from their last known source activity, not their last delivery; the next real turn reports working again. sessionId is optional and comes from the thread's Hermes session, key is the thread key, title is the issue title and link identifies the agent session. Optional links carries additional source links as [{"text","url"}] from input metadata when present; existing issue/session titles and primary links remain unchanged. Board state retains source metadata for activity updates without inputs, including updates from another replica. error carries the failure or denied command. at is an epoch-millisecond version that increases when a new projection is persisted; it orders updates, not receipt time. Optional sourceActivityAt is epoch milliseconds of the last known real accepted, queued, tool, message, thought, todo or terminal lifecycle activity. Even activity coalesced within BOARD_REFRESH_INTERVAL persists a new immutable projection with its source time. Silence, replay and unchanged status checks do not create activity or a fresh version. A repaired source change uses its known activity time, not query time. Settled and superseded occurrences reject late callbacks.
The complete latest projection and its pending delivery are stored atomically with a retained index, including terminal threads after driver cleanup. Driver lifecycle transitions await only Redis persistence; board delivery and platform/Slack HTTP remain detached. Immediate delivery keeps the BOARD_RETRIES budget. After releasing delivery ownership, the sender checks for a newer due pending version and drains it immediately, including reports persisted by another replica while delivery was finishing. The exhausted version itself waits for startup or hourly reconciliation rather than retrying in a tight loop. An acknowledgement clears only its exact version, so it cannot discard a newer report. Reconciliation also replays acknowledged latest projections to restore lost receiver history, byte-for-byte with the same at, source time, session, links, error and reporting policy. Redis token-owned locks coordinate replicas; at-least-once delivery can repeat an identical payload.
Receiver integration must deduplicate equal/older at without another announcement and expire entries at sourceActivityAt + HERMES_TTL, using only the remaining delay and ignoring expired replays. Receipt time is transport diagnostics, never source activity. Deploy that receiver support before enabling repeated replay; legacy JSON fields and the endpoint remain unchanged. Receiver rendering remains event-driven at 60 seconds, and daily message rotation belongs to the receiver, not this sender.
Active-run repair uses the established HERMES_URL and HERMES_API_KEY through GET /v1/runs/{id}; it never sends driver input, recovers a driver thread, or emits platform activities. The driver record and board version must still match the snapshot taken before the query. Explicit stop and denied-stop state override raw run status, while an interrupted run eligible for driver resubmission stays running. Hermes updated_at is seconds of a status write, not user activity: only a recognized activity/terminal event can supply a recovery source time, and interruption queries never use it because GET can synthesize it. Otherwise repair preserves a known board source time or the original follower start time; unknown or expired provenance is skipped. Terminal history with neither a retained projection nor a surviving driver source cannot be reconstructed, and no timestamps are invented for it. Existing refresh, retry and TTL setting names remain unchanged.
| Method | Path | Description |
|---|---|---|
GET | /healthz | Liveness: answers 200 ok. |
GET | /readyz | Readiness: answers 200 ok, and 503 once a shutdown starts draining. |
POST | /v1/hooks/linear | Linear agent session webhooks: 400 on a bad signature, a stale timestamp or a payload that fails the schema, 503 when the event could not be queued, else 200. |
| any | any other | Answers 404. |
Every line carries a message that is a complete sentence fixed per event, its source class as context, and its ids, numbers and reasons as fields at the root of the JSON object ({"level":"log","message":"Created a run for the thread.","context":"DriverThreadService","run":"...","thread":"...","replayed":false}); text prints the same fields inline. Durations are durationMs; tokens and bodies are never logged.
info: one line per HTTP request (method, path, status, duration, remote address; /healthz and /readyz only at debug), webhook verification or rejection with the Linear-Delivery and Linear-Event headers, each session event, acknowledgement and thread input, run creation, busy backoff, steers, stops, follows and takeovers, finished runs with status and duration, posted activities, token mints, Redis connections, the recovery sweep summary, the drain on shutdown and the runs and threads it hands over.debug: every run stream event, Hermes call (method, path, status, duration), queued and coalesced activity and session update, thread lock and run lease acquire, renew and release, and Redis transaction retry.warn and error: anything retried, dropped or failed, with its error.Run from apps/linear, tests/post-fixture.ts signs a fixture from tests/testdata with the webhook secret for the webhook URL given as its second argument, stamps a fresh webhookTimestamp, a fresh agentActivity.id and a fresh Linear-Delivery header, and POSTs it. It runs on Node 26 as is, since Node strips the types. Name a session Linear already opened to watch its activities land:
LINEAR_WEBHOOK_SECRET=... node tests/post-fixture.ts tests/testdata/created-delegation.json https://bridge.example.com/v1/hooks/linear <agentSessionId>
The application lives in apps/linear of the workspace and runs on the shared core in packages/core; see the repository README.
pnpm install
pnpm lint
pnpm test
pnpm build
pnpm --filter @kilic.dev/hermes-bridge-linear start
Content type
Image
Digest
sha256:27377d77d…
Size
74.3 MB
Last updated
1 day ago
docker pull cenk1cenk2/hermes-bridge-linear