/packs / linear

linear

Linear itself, in the workspace.

  • integration
  • sources
  • Linear

As of yardr at commit 67389f7, 11 Nov 2026. The pack's own files keep the author's words: issue · bead, repo · depot, workspace · yard, agents · crew, agent · crew member, merges · lands.

01 / apply it

yardr pack apply linear

yardr carries this pack in its binary; the name is enough. yardr pack check linear shows what it would change first. It goes into the workspace itself, and into no repo · depot.

yardr pack apply https://yardr.dev/packs/linear/

From this site, as the files below.

02 / the files

Every file, as it ships.

# Linear as a pack of kind source: the integration, imported into the yard
# and into no depot. What Linear is: how its webhooks are verified and
# read, and how the yard replies, opens a thread and sets a status in it.
# What a stage means there is the flow pack's to say (sources.<name>:
# hears); until the yard has this source, an import of that pack skips the
# lines, and the next apply sets them.
#
#   yardr pack apply linear
#
# The source is named linear: yardr knows Linear as this pack alone. The
# apply folds what the yard recorded under linear-declared into linear.
name: linear
kind: source
setup: SETUP.md

sources:
  linear:
    declaration: linear.yaml
#!/bin/sh
# A verified Linear webhook to the yard's envelope, as Linear{} makes it:
# stdin {"headers":{…},"body":{…}}, stdout the envelope. Needs jq.
# internal/hooks feeds it the Linear bodies its tests already have and
# compares the envelopes with Linear{}.Normalize's.
exec jq -c '
def first_of(f): [f | select(. != null and . != "")] | .[0] // "";
# trunc is hooks.truncate: whitespace collapsed, at most 500 runes. (\s
# here is ASCII space where Go splits on every unicode space; the
# envelopes differ only for a text with an exotic space in it.)
def trunc: (gsub("^\\s+|\\s+$"; "") | gsub("\\s+"; " ")) | if length > 500 then .[0:499] + "…" else . end;
def ordered: reduce .[] as $x ([]; if index($x) then . else . + [$x] end);
def mentions($md; $doc):
  ( [ $doc | select(. != null) | (if type == "string" then (try fromjson catch null) else . end)
      | .. | objects | select(.type == "suggestion_userMentions") | select(.attrs.id | type == "string") | .attrs.id ]
  + [ ($md // "") | scan("https://linear\\.app/[^/\\s]+/profiles/([A-Za-z0-9][A-Za-z0-9._-]*)")
      | .[0] | ascii_downcase | sub("\\.+$"; "") ]
  + [ ($md // "") | gsub("(?s)```.*?(```|$)|`[^`\n]*`"; " ")
      | scan("(?:^|[^A-Za-z0-9_/])@([A-Za-z0-9][A-Za-z0-9._-]*)")
      | .[0] | ascii_downcase | sub("[.,:;!?]+$"; "") ]
  ) | ordered;
def fields($i):
  ( { team:    [$i.team.key, $i.team.name],
      project: [$i.project.name],
      label:   [$i.labels[]?.name] }
    | map_values(map(select(. != null and . != ""))) | with_entries(select(.value | length > 0)) )
  | if length == 0 then null else . end;
def changed($u):
  ( ($u | if type == "object" then . else {} end) | [keys[] | select(. != "updatedAt")] | sort | join(", ") ) as $ks
  | if $ks == "" then "" else ("changed " + $ks | trunc) end;

.body as $p | ($p.data // {}) as $d
| { source: "linear", type: (($p.type // "") | ascii_downcase), action: ($p.action // ""), url: ($p.url // ""),
    actor: ( if $p.actor != null then {id: ($p.actor.id // ""), name: ($p.actor.name // "")}
             elif $d.user != null then {id: ($d.user.id // ""), name: ($d.user.name // "")}
             else {id: ($d.userId // ""), name: ""} end ) }
| if .type == "comment" then . +
    { thread: ( if $d.issue != null
                then {id: ($d.issue.id // ""), title: ($d.issue.title // ""),
                      url: (if ($d.issue.url // "") == "" then ($p.url // "" | sub("#.*$"; "")) else $d.issue.url end)}
                else {id: (if ($d.issueId // "") == "" then ($d.id // "") else $d.issueId end),
                      title: "", url: ($p.url // "" | sub("#.*$"; ""))} end ),
      text: (($d.body // "") | trunc), message: ($d.id // ""), parent: ($d.parentId // ""), entity: ($d.id // ""),
      mentions: mentions($d.body; $d.bodyData), fields: fields($d.issue // {}) }
  elif .type == "issue" then . +
    { thread: { id: ($d.id // ""), title: ($d.title // ""),
                url: (if ($d.url // "") == "" then ($p.url // "") else $d.url end) },
      assignee: first_of($d.assigneeId, $d.assignee.id),
      delegate: first_of($d.delegateId, $d.delegate.id),
      text: ( if $p.action == "update" then changed($p.updatedFrom) else (($d.description // "") | trunc) end ),
      entity: ($d.id // ""), mentions: mentions($d.description; null), fields: fields($d) }
  else . + { thread: { id: ($d.id // ""), title: ($d.title // ""), url: ($p.url // "") }, mentions: [] }
  end
# sender is who did it, the id the envelope names as actor: the hear key
# matches it against the user identities the yard holds in linear, and the
# core stamps speaker from the match.
| (.actor.id // "") as $sender | if $sender != "" then .fields = ((.fields // {}) + {sender: [$sender]}) else . end
'
#!/bin/bash
# thread: what the yard does in a Linear thread: reply, posted, read, open,
# opened, state. One JSON object on stdin, one on stdout, as the thread key
# of the declaration says. LINEAR_API_KEY (a personal key, sent bare, or
# anything else, sent as a Bearer token) and YARDR_SOURCE_BASE, the
# declaration's base, are in the environment. Needs curl and jq.
set -euo pipefail
in=$(cat)
arg() { jq -r --arg k "$1" '.[$k] // ""' <<<"$in"; }

case "$LINEAR_API_KEY" in
  lin_api_*|"Bearer "*) auth="$LINEAR_API_KEY" ;;
  *) auth="Bearer $LINEAR_API_KEY" ;;
esac

# raw <query> <variables as JSON>: the whole GraphQL answer. The
# credential goes to curl as a config line on its stdin, written by the
# shell itself: an argument can be read by every process on the machine
# while curl runs.
raw() {
  printf 'header = "Authorization: %s"\n' "$auth" \
    | curl -sS --fail-with-body -K - "$YARDR_SOURCE_BASE/graphql" \
    -H 'Content-Type: application/json' \
    -d "$(jq -n --arg q "$1" --argjson v "$2" '{query: $q, variables: $v}')"
}

# gql <query> <variables as JSON>: the data of the answer, or exit 1 with
# Linear's errors on stderr.
gql() {
  local out
  out=$(raw "$1" "$2")
  jq -e '.errors | not' <<<"$out" >/dev/null || { jq -c .errors <<<"$out" >&2; exit 1; }
  jq -c .data <<<"$out"
}

# missing: whether the answer on stdin is only that Linear has no such
# entity: what posted and opened answer {} to.
missing() {
  jq -e '[.errors[]?.message // empty | select(contains("Entity not found"))] | length > 0' >/dev/null
}

case "$1" in
reply)   # the key is the comment's own id: Linear takes ours, so a retry finds it
  gql 'mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success comment { id url } } }' \
      "$(jq -c '{input: ({id: .key, issueId: .thread, body: .body} + (if .parent != "" then {parentId: .parent} else {} end))}' <<<"$in")" \
    | jq -c 'if .commentCreate.success then .commentCreate.comment else error("commentCreate did not succeed") end' ;;

posted)  # {} for none: a retry asks before it posts, so nothing is posted twice
  resp=$(raw 'query($id: String!) { comment(id: $id) { id url issue { id } } }' "$(jq -c '{id: .key}' <<<"$in")")
  if jq -e '.errors | not' <<<"$resp" >/dev/null; then
    jq -c --arg t "$(arg thread)" '.data.comment | if . != null and .issue.id == $t then {id, url} else {} end' <<<"$resp"
  elif missing <<<"$resp"; then
    printf '%s' '{}'
  else
    jq -c .errors <<<"$resp" >&2; exit 1
  fi ;;

read)    # every page, which a declared call cannot follow
  cursor=null; all='[]'
  while :; do
    page=$(gql 'query($id: String!, $c: String) { issue(id: $id) { comments(first: 100, after: $c) {
                  nodes { id body createdAt url user { id name } botActor { id name } parent { id } } pageInfo { hasNextPage endCursor } } } }' \
               "$(jq -c --argjson c "$cursor" '{id: .thread, c: $c}' <<<"$in")")
    all=$(jq -c --argjson a "$all" '$a + .issue.comments.nodes' <<<"$page")
    [ "$(jq -r .issue.comments.pageInfo.hasNextPage <<<"$page")" = true ] || break
    cursor=$(jq -c .issue.comments.pageInfo.endCursor <<<"$page")
  done
  jq -c '{messages: [sort_by(.createdAt)[] | {id, parent: (.parent.id // ""), author: ((.user // .botActor // {}) | {id: (.id // ""), name: (.name // "")}), at: .createdAt, body, url: (.url // "")}]}' <<<"$all" ;;

open)    # the team is given by key or name; the mutation wants its id. An assignee is a user's id
  team=$(gql 'query($t: String!) { teams(filter: {or: [{key: {eq: $t}}, {name: {eq: $t}}]}) { nodes { id } } }' \
             "$(jq -c '{t: .in}' <<<"$in")" | jq -r '.teams.nodes[0].id // ""')
  [ -n "$team" ] || { echo "no team $(arg in)" >&2; exit 1; }
  gql 'mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id title url } } }' \
      "$(jq -c --arg t "$team" '{input: ({id: .key, teamId: $t, title: .title} + (if .body != "" then {description: .body} else {} end)
                                       + (if (.assignee // "") != "" then {assigneeId: .assignee} else {} end))}' <<<"$in")" \
    | jq -c 'if .issueCreate.success then .issueCreate.issue else error("issueCreate did not succeed") end' ;;

opened)  # posted's sibling for an open whose answer was lost: the key is the issue's own id
  resp=$(raw 'query($id: String!) { issue(id: $id) { id title url } }' "$(jq -c '{id: .key}' <<<"$in")")
  if jq -e '.errors | not' <<<"$resp" >/dev/null; then
    jq -c '.data.issue // {}' <<<"$resp"
  elif missing <<<"$resp"; then
    printf '%s' '{}'
  else
    jq -c .errors <<<"$resp" >&2; exit 1
  fi ;;

state)   # started, review or done: each team has its own statuses, so pick one by type
  cur=$(gql 'query($id: String!) { issue(id: $id) { state { id name type position }
               team { states { nodes { id name type position } } } } }' "$(jq -c '{id: .thread}' <<<"$in")")
  want=$(jq -c --arg s "$(arg state)" '
    .issue.team.states.nodes | sort_by(.position) as $all
    | if   $s == "started" then [$all[] | select(.type == "started")][0]
      elif $s == "review"  then ([$all[] | select(.type == "started" and (.name | test("review"; "i")))][0]
                                  // [$all[] | select(.type == "started")][0])
      elif $s == "done"    then [$all[] | select(.type == "completed")][0]
      else null end' <<<"$cur")
  [ "$want" != null ] || { echo "this team has no status for $(arg state)" >&2; exit 1; }
  was=$(jq -r .issue.state.name <<<"$cur")
  if [ "$(arg forward)" = true ]; then
    # a stage map's move, which never moves backwards: an issue at or past
    # the status stays where it is; one canceled, or in a status of an
    # unknown type, is past every status
    rank='{"triage":0,"backlog":1,"unstarted":2,"started":3,"completed":4,"canceled":5}'
    if jq -e --argjson w "$want" --argjson r "$rank" \
         '.issue.state as $c | ($r[$c.type] // 5) > $r[$w.type] or ($c.type == $w.type and $c.position >= $w.position)' \
         <<<"$cur" >/dev/null; then
      jq -c '{status: .issue.state.name, moved: false}' <<<"$cur"; exit 0
    fi
  elif jq -e --argjson w "$want" '.issue.state.id == $w.id' <<<"$cur" >/dev/null; then
    # thread state moves exactly: only an issue already in the status stays
    jq -c '{status: .issue.state.name, moved: false}' <<<"$cur"; exit 0
  fi
  gql 'mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { issue { state { name } } } }' \
      "$(jq -c --argjson w "$want" '{id: .thread, input: {stateId: $w.id}}' <<<"$in")" \
    | jq -c --arg was "$was" '{status: .issueUpdate.issue.state.name, moved: true, was: $was}' ;;

*) echo "unknown: $1" >&2; exit 2 ;;
esac
# Linear as a declared source, from the sketch
# (origin/sketch/source-in-a-pack:docs/design/source-in-a-pack/linear-pack):
# how the core verifies a Linear delivery, and the command that turns a
# verified body into the envelope; what the yard does in a Linear thread,
# by one command per thing; and Linear's issue URLs as a bead's link.
# internal/hooks holds it to golden files: the envelopes for the bodies
# its tests have, and what the stand-in receives for reply, posted,
# read, open and state.
#
# The name is linear: the pack is what yardr knows of Linear.
#
# A command is a list, run without a shell. A path that does not start
# with / is a file beside this one, which `yardr source add` copies into
# the yard.
name: linear
about: Linear issues and comments. Hears its webhooks and speaks in its threads.

# The name of the variable, never the value. A command gets it in its
# environment under this name, and gets no other variable of the yard.
# hook.normalize is handed no credential at all.
#
# The header is how a declared call, one of the calls below, sends it.
# Without one those calls go out with no credential at all. Linear takes
# an OAuth access token as a Bearer token, and the yard's own is an OAuth
# application's token, so Bearer is the form here. Linear's authentication
# docs, at linear.app/developers/graphql, show a personal API key sent
# bare, as "Authorization: <API_KEY>", and do not say that Bearer takes one
# too. A declared call therefore wants an OAuth token. linear/thread builds
# its own header, a lin_api_ key bare and anything else as Bearer, and
# takes either.
credential:
  env: LINEAR_API_KEY
  header: "Authorization: Bearer {credential}"

# ---- what it sends us ----------------------------------------------------

hook:
  # Adapter.Verify stays data, checked by the core: the signing secret is
  # handed to no command, and an unverified body reaches none.
  verify:
    scheme: hmac-sha256
    signed: body
    signature: { header: Linear-Signature, encoding: hex }
    delivery:  { header: Linear-Delivery }
    timestamp: { body: webhookTimestamp, unit: ms, window: 1m }
  bodies: unique
  # Linear's deliveries follow issue activity, not a rhythm, so silence is never quiet.
  untimed: true

  # Adapter.Normalize. stdin {"headers":{…},"body":{…}}, stdout the
  # envelope. The core sets source and delivery itself.
  normalize: [linear/normalize]

  # The keys its events' fields may carry: a subscription's --where may
  # name them beside type, action, team, project, repo and label. sender
  # is the Linear user id of who did it, the field the hear key below
  # matches.
  keys: [team, project, label, sender]

# ---- who is heard ---------------------------------------------------------
# A delivery is kept with its speaker named where the sender is a user of
# the yard, matched by identity: fields.sender holds the Linear user id,
# which normalize also sets as actor.id. The core adds fields.speaker for
# a kept delivery (the yard user's name, or outside where the hook's
# --unknown word allows an unmapped sender); speaker is the derived
# filter key for this hear, and stands in no hook.keys above, which names
# only what normalize may set.

hear:
  field: sender
  by: identity

# ---- what the yard does in its threads ------------------------------------
# One command per thing, each handed its object on stdin and the
# credential under LINEAR_API_KEY, with YARDR_SOURCE_BASE from base below.
# linear/thread is the sketch's script: reply posts the body's comment with
# the key as its own id, posted finds what a key left ({} for none), read
# lists the thread's messages oldest first, open starts an issue in the
# team in, opened finds what an open left ({} for none: posted's sibling
# for open), and state moves to what the word means: exactly, or never
# backwards with forward (a stage map's move). The shapes are in
# docs/integrations.md.

thread:
  reply:  [linear/thread, reply]      # in  {thread, parent, body, key}      out {id, url}
  posted: [linear/thread, posted]     # in  {thread, key}                    out {id, url} or {}
  read:   [linear/thread, read]       # in  {thread}                         out {messages: [...]}
  place:  team                        # what open's in names
  open:   [linear/thread, open]       # in  {in, title, body, key, assignee?} out {id, title, url}
  assignee: id                        # the assignee open takes: a user's id
  opened: [linear/thread, opened]     # in  {in, key}                        out {id, title, url} or {}
  state:  [linear/thread, state]      # in  {thread, state, forward?}        out {status, moved, was?}
  states: [started, review, done]     # the words it has a status for

# ---- its issues as a bead's link ------------------------------------------
# The host its issue URLs are on, what reads an issue from a URL's path,
# and over the groups: the issue's one fixed form, and the id a thread
# command takes for it.

link:
  host: linear.app
  issue: '^/([^/]+)/issue/([A-Z][A-Z0-9]*-[0-9]+)'
  fixed: "https://linear.app/{1}/issue/{2}"
  thread: "{2}"

# ---- (today) what an agent may ask of it, through `yardr call` ------------
# Unchanged, and separate from the above: declared HTTPS requests, granted
# one by one. A command is never something an agent calls.

base: https://api.linear.app
calls:
  viewer:
    kind: read
    request: { method: POST, path: /graphql, body: { query: "{ viewer { id name } }" } }
check: viewer
# Setting up Linear

The pack wires Linear into the yard as the declared source `linear`: its
webhooks are verified and read, and the yard replies, opens issues and
sets their status through the commands the pack carries. What a stage
means in Linear is a flow pack's to say, not this file's.

1. **The webhook, in Linear.** Settings → API → Webhooks: add one with the
   URL `https://<public host>/hooks/<hook name>` — the path ends in the
   hook's name, the one `yardr hook add` is given below — and tick the
   events Issues and Comments. The yard reads issue and comment deliveries
   only; anything else it cannot use.
2. **The hook and its secret.** The webhook's signing secret goes in a file,
   mode 0600, which the hook names. A new file takes its mode from the
   shell's umask, so narrow it first. The secret is never a command's
   word: that line would stay in the shell's history. Each command below
   is one thing to type, in a block of its own; run the second, type or
   paste the secret blind and press Enter:
   ```sh
   umask 077
   ```
   ```sh
   IFS= read -rs v && printf '%s' "$v" > ~/.yardr/linear.secret; unset v
   ```
   ```sh
   chmod 600 ~/.yardr/linear.secret
   ```
   ```sh
   yardr hook add linear --source linear --secret-file ~/.yardr/linear.secret --unknown allow
   # hook linear saved: POST /hooks/linear (linear)
   ```
   One hook serves every crew member. `--unknown allow` hears every
   workspace member's comment: a delivery from a sender no yard user maps
   is kept with the speaker `outside`. Give each person their identity
   (step 6) and their deliveries arrive named as them, which a
   subscription may filter with `--where speaker=<user>`.

   A crew member sets the hook up without handling the secret, by two
   asks on one bead, the secret first: it asks for the signing secret
   (`yardr bead ask <id> "…" --secret-hook linear --source linear
   --context "…"`) and then for the hook itself
   (`yardr bead ask <id> "…" --run "hook add linear --source linear --unknown allow"
   --context "…"`). The operator pastes the secret where Linear shows
   it, typed unseen; the yard's server writes it to
   `<home>/hooks/linear.secret` (mode 0600), which the hook then names,
   and it is in no note or pane. Approving the hook then makes
   `POST /hooks/linear`. `--map` and `--as`, and replacing a hook, stay
   in a terminal; the manual steps above remain available.
3. **The crew member's key.** `LINEAR_API_KEY` in an env file of the crew
   member's own, mode 0600, never in the store. Have the crew member ask
   for it, and answer on its card, in footplate or in `yardr chat`
   (`ctrl+o`), where the field shows dots:
   ```sh
   yardr bead ask <bead> "The token it answers with" --secret linear --for-crew <name> --context "The token of the Linear OAuth application made for it."
   ```
   The yard's server writes it into the crew member's env file, or with
   none set into `~/.yardr/crew/<name>.env`, which is then set as `yardr
   crew set` sets it. The running session does not see the new value:
   restart the crew member after giving it. By hand it is yours instead.
   A new file takes its mode from the shell's umask, so narrow it first.
   The token is never a command's word: that line would stay in the
   shell's history. Each command below is one thing to type, in a block
   of its own; run the second, type or paste the token blind and press
   Enter:
   ```sh
   umask 077
   ```
   ```sh
   IFS= read -rs v && printf 'LINEAR_API_KEY=%s' "$v" > ~/.yardr/crew/<name>.env; unset v
   ```
   ```sh
   chmod 600 ~/.yardr/crew/<name>.env
   ```
   ```sh
   yardr crew set <name> env_file=~/.yardr/crew/<name>.env
   ```
   The token is that of a Linear OAuth application made for the crew
   member, so it acts as the application's own user and not as a person.
   Linear takes an OAuth token as `Authorization: Bearer <token>`, and the
   pack sends it that way. A personal key makes the crew member speak as
   that person. Linear's docs (https://linear.app/developers/graphql) show
   one sent bare, as `Authorization: <key>`, and do not say that Bearer
   takes it too. The thread commands send a `lin_api_` key bare, so they
   work with either token. The declared calls (`yardr call linear viewer`,
   `yardr source check linear`) send every key as Bearer and read it from
   the source's own env file (`yardr source key linear`), so put an OAuth
   token there. Restart the crew member after changing the file.
4. **Its identity.** The id and the profile key of the user the token acts
   as, looked up in Linear by hand — both:
   ```sh
   yardr crew identity <name> --source linear --id <user id> --key <handle>
   ```
   A crew member setting itself up proposes it instead, from its session,
   and the operator approves it on the card:
   ```sh
   yardr bead ask <bead> "Who am I in Linear?" --run "crew identity <name> --source linear --id <user id> --key <handle>"
   ```
   A mention is matched by the handle when the delivery carries markdown
   only, and by the id otherwise: with the id alone, the test of 11 Oct
   saw a mention arrive as `participant` instead of `mentioned`.
5. **The grants.** Opening issues and setting their status are the
   operator's to grant:
   ```sh
   yardr crew set <name> open_threads=linear set_state=linear
   ```
6. **Test it.** As the crew member: open an issue, mention it from Linear,
   move it:
   ```sh
   yardr thread open linear --in <team key> - -b -   # stdin: the title (Hello from the yard), then the body (testing the wiring)
   # comment @<handle> on the issue in Linear: it lands in the crew member's inbox as mentioned
   yardr thread state <thread id> started
   ```
   `yardr thread list <name>` shows the thread's id.
   To open an issue assigned to a person, give the yard's user their
   Linear user id once, then name them:
   ```sh
   yardr user identity <user> --source linear --id <their user id>
   yardr thread open linear --in <team key> --assign <user> -   # stdin: the title (For you)
   ```
   The same identity names who spoke on their deliveries: a comment or an
   issue from them arrives with them as its speaker, and a subscription
   may filter `--where speaker=<user>`. An id names one user: one that
   another user already holds is refused.
7. **The stage map.** When a bead enters a stage, the issue it tracks under
   this source moves with it: the hook carries the map, and the crew
   member it acts as. `hook add` replaces the definition, so the whole
   map is given each time, with `--unknown allow` again:
   ```sh
   yardr hook add linear --source linear --secret-file ~/.yardr/linear.secret --unknown allow --as <name> \
       --map task:new=started --map task:review=review --map task:merged=done
   # moves tracked linear issues as <name>: task:new=started …
   ```