obsidian-linear-sync
Mirror a Linear project into an Obsidian vault as Markdown. One-way: Linear → Markdown. Nothing is ever written back — no mutating Linear tool, under any circumstances.
Division of labour you must respect:
- You resolve the project and fetch the data (or let the script fetch it, see below).
- The script (
scripts/linear_sync.py, relative to this skill directory) makes every reproducible decision and writes every byte. You do not render Markdown yourself, do not compute filenames, and do not edit a note withEdit/Write. That is exactly whyEditandWriteare absent fromallowed-tools.
Result per project:
01 Projects/Linear/Website Redesign/
Website Redesign.md ← index note
ENG-42 Rework the checkout flow.md ← one file per ticket
ENG-51 Ship the new nav.md
Argument parsing
Arguments: <linear-project-url | project name | project UUID> [flags]
- Positional (required): the project URL, name, or UUID. From
https://linear.app/acme/project/website-redesign-a1b2c3d4e5f6/the slugwebsite-redesign-a1b2c3d4e5f6is enough. If it is missing, list the user's projects (list_projects) and ask which one. --dry-run: full run, but no writes, no folder creation, no watermark advance. Recommend this for a first run on any project.--force: ignore all watermarks and refetch every ticket. Implies--census.--census: force the reconciliation pass (which tickets disappeared) even if the TTL has not elapsed.--no-comments: do not mirror comments. The choice is stored on the index note assync comments, so a later bare run reuses it instead of churning every file.--only ENG-42,ENG-51: just these tickets, skipping the delta pass.--max-fetch <n>(default 40): if the fetch plan exceeds this, state the estimated number of calls and ask before continuing.--lang en|de(defaulten): language of the rendered note content — section headings, status group names, durations. Stored assync langon the index note; a bare run reuses it, so a vault never flips language by accident. Operator messages are always English.--timezone <IANA>(default: system zone): timezone for rendered timestamps.--keep-checkboxes: keep- [ ]instead of converting to- ☐(see below).--mcp: force path B (MCP) even when an API key is available.
Examples:
/obsidian-linear-sync https://linear.app/acme/project/website-redesign-a1b2c3d4e5f6/ --dry-run/obsidian-linear-sync "Website Redesign"/obsidian-linear-sync "Website Redesign" --only ENG-42/obsidian-linear-sync "Website Redesign" --force --lang de
Preconditions
Run from the vault root. All paths below are relative to it.
test -d "00 Maps of Content" && test -d "04 Permanent" && test -f ".prettierrc.json" \
|| echo "NOT AT VAULT ROOT — abort"
command -v prettier >/dev/null || echo "prettier missing — abort"
python3 -c "import sys; assert sys.version_info >= (3,9)" || echo "Python too old — abort"
test -f "$SC" || echo "scripts/linear_sync.py not found next to SKILL.md — abort"
If the script is missing, this skill was installed by a tool that copies only SKILL.md.
The skill cannot work without scripts/linear_sync.py — do not attempt to reimplement it
inline. Tell the user to copy the scripts/ directory alongside SKILL.md, or to point SC at
a checkout of the skill repository.
No PyYAML and no requests are needed — the script is pure standard library. Do not copy an
import yaml precheck from another skill; this one deliberately has no such dependency.
Loading the tool schemas
The Linear MCP tools are deferred. Load them first:
ToolSearch with
select:mcp__claude_ai_Linear__list_projects,mcp__claude_ai_Linear__get_project,mcp__claude_ai_Linear__list_issues,mcp__claude_ai_Linear__get_issue,mcp__claude_ai_Linear__list_comments
list_milestones is not needed — get_project(includeMilestones=true) returns milestones
inline.
Two fetch paths
Set SC="<this skill directory>/scripts/linear_sync.py".
Path A — GraphQL (preferred when an API key exists). One command fetches and writes everything; no ticket content passes through your context and there is no transcription risk:
python3 "$SC" sync <<'JSON'
{"opts": {"dry_run": true}, "project": "website-redesign-a1b2c3d4e5f6"}
JSON
The key is looked up in this order: LINEAR_API_KEY, LINEAR_API_KEY_FILE,
~/.config/linear/api-key. It is never printed and never interpolated into an error message.
A read scope is sufficient; the script issues three GraphQL queries and zero mutations. If
no key is found the script aborts with a hint — then ask the user whether to create one or
use path B. Never guess a key and never search the keychain for one.
fetch prints the same payload without writing, which is useful for debugging.
Path B — MCP (no key needed). You fetch through the Linear MCP tools and hand over the
payload. This always works, but every changed ticket description must pass through your context
and into the payload; a single get_issue result can be tens of kilobytes. Use
details_dir/comments_dir for that (see step 4).
Both paths end in the same apply code, produce byte-identical files, and hold the same
guarantees. The steps below describe path B; on path A, sync covers steps 1–5.
Sync algorithm (path B)
1. Cheap project probe (1 call, ~100 tokens)
list_projects(query=<argument>, fields=["id","name","url","updatedAt"], limit=5)
Exactly one match must remain; on several, show the candidates and ask. Do not call
get_project directly — its description costs several kilobytes.
2. Cheap issue pass
Build the plan payload and let the script hand you the watermark:
python3 "$SC" plan <<'JSON'
{"opts": {...}, "project_probe": {"id": "...", "name": "...", "updatedAt": "..."}, "issues": []}
JSON
A first plan call with an empty issues list returns watermark, census_due,
index_exists and need_project_detail. Then:
Delta pass (default) — one call that returns zero rows in the steady state:
list_issues(project=<id>, includeArchived=true, limit=250, orderBy="updatedAt",
updatedAt=<watermark minus 60 seconds>,
fields=["id","title","updatedAt","createdAt","completedAt","startedAt","dueDate",
"archivedAt","status","statusType","priority","labels","assignee","createdBy",
"team","url","gitBranchName","projectMilestone","parentId","estimate"])
Census pass (when census_due): the same call without updatedAt, plus the slim roster
list_issues(project=<id>, includeArchived=true, limit=250, fields=["id","archivedAt"])
passed as roster.
Details on includeArchived, the 60-second skew and pagination: references/sync-algorithm.md.
3. Fetch plan
Call plan again, now with the real issues. You get verdicts and fetch. Only for
identifiers in fetch do you pull details:
get_issue(id="ENG-42", includeRelations=true)
list_comments(issueId="ENG-42", limit=250, orderBy="createdAt")
If len(fetch) exceeds --max-fetch, state the cost and ask.
4. Apply
python3 "$SC" apply < payload.json
Payload fields: opts, project (from get_project, only when need_project_detail,
otherwise the probe fields), issues (all rows from step 2), details, comments, and
optionally roster, missing_reasons, watermark_new.
For long descriptions use details_dir / comments_dir instead of details / comments.
Write one <IDENT>.json file per ticket containing the raw MCP result verbatim, and pass
only the directory paths. The script reads them and produces byte-identical output. Copy the
description text unchanged — do not shorten, reformat, or translate it.
The script runs prettier itself via prettier --stdin-filepath. Do not call prettier
separately, and never with --parser markdown: the *.md override in .prettierrc.json sets
singleQuote: false, and without it every frontmatter wikilink is rewritten to single quotes.
5. Classify disappeared tickets
For each identifier in missing_candidates (census only), one targeted get_issue(id):
| Result | missing_reasons value |
|---|---|
succeeds, projectId ≠ our project |
moved (name the new project in the report) |
| error / not found | deleted |
Files are never deleted and never moved — only sync missing since and
sync missing reason are set.
6. Re-index
qmd update && qmd embed
Skip on --dry-run. If qmd embed runs long, start it in the background.
Determinism axiom
The managed region of every file is a pure function of the Linear payload. The script guarantees this; do not undermine it:
- A second run with no Linear changes writes zero files. That is the acceptance criterion:
git statusstays clean for the project folder. created:andlinear updated:come from Linear.updated:andsynced:are local timestamps that only advance when something actually changed.sync hash:covers the managed body only and is computed after prettier. It carries a version prefix so changing the formula cannot strand notes in permanent conflict.- Filenames and content are NFC-normalized. Without that, any title containing
öor—produces a phantom rename on every run on APFS.
Ticket references as wikilinks
Linear encodes ticket mentions in three shapes, all of which occur in real data: inline tags
(<issue id="…" href="…">ENG-42</issue>), plain markdown links
([ENG-42](https://linear.app/…/issue/ENG-42/…)), and bare identifier text in comments
(→ ENG-42:).
- Identifier in this project →
[[ENG-42 Full Title|ENG-42]]. Piped, not bare: Obsidian indexesaliases:for the quick switcher but does not resolve an already-written[[ENG-42]]through them. Inside a table cell the pipe is escaped as\|. - Identifier in another project →
[ENG-9](https://linear.app/…). - Unknown identifier → plain text. A wikilink that would dangle is never emitted.
- The prefix must be a known team key of the workspace. Without that gate
UTF-8,COVID-19,HTTP-2andISO-8601would all become links. - Nothing is rewritten inside fenced code blocks or inline code.
- People and initiatives are linked only when a note for them actually exists in the vault.
Verified regexes and the full pipeline: references/write-pipeline.md.
Protected section
The managed region lies between <!-- linear:begin … --> and <!-- linear:end -->.
Everything from <!-- linear:end --> to end of file belongs to the user and is never
overwritten. Markers are matched by their linear:begin/linear:end token, so the localized
wording can change without orphaning existing notes.
| Situation | Behaviour |
|---|---|
| New file | Markers plus the notes heading are created from the start |
| Marker missing in an existing note | Do not write. Report MARKER_MISSING, continue the batch. Even --force refuses — a guessed boundary can destroy prose |
| Prose above the marker | sync hash mismatch → CONFLICT. File untouched, reported |
| Ticket renamed | File is renamed, the old filename moves into aliases (max 4), notes survive |
Renames
Renaming uses os.rename in the script, not obsidian rename: the latter needs the app
running and triggers update-time-on-edit on the file and every linking file, turning one
rename into N timestamp changes and N git diffs. Internal links point at filenames but are
regenerated on the next sync anyway, so churn costs nothing in a machine-generated mirror. The
previous filename is additionally kept as an alias so hand-written links elsewhere still resolve.
Task lists
- [ ] becomes - ☐ and - [x] becomes - ☑ (disable with --keep-checkboxes). A mirrored
checkbox is a lie — ticking it does nothing and the next sync resets it — and dozens of
acceptance criteria would flood any vault-wide task query.
Report
After the run, report concisely:
- Project, folder, mode (live/dry-run, delta/census), effective options
- Number of MCP calls (path B) or that the GraphQL path was used
- Counts:
created/updated/unchanged/renamed/skipped/missing/archived/failures - Per non-unchanged ticket: identifier, verdict, old → new on rename, one line of reasoning on conflict or failure
- Every
warningsentry from the script - Whether
qmd updateran
Report MARKER_MISSING, CONFLICT and orphaned tickets loudly, with paths. A mirror that
silently accumulates problems is a mirror nobody trusts.
Error handling
- One broken ticket never aborts the run — report it and continue.
- Ask only at batch level: failed precondition, ambiguous project,
--max-fetch. - Two files claiming the same
id→ error for that identifier only, name both paths. - If
prettierfails, the script aborts. Do not work around it. synced throughadvances only when the run had no failures; otherwise the affected identifiers land insync pendingand are refetched next time.
Absolute prohibitions
- Never write to Linear. No
save_issue,save_comment,create_attachmentor any other mutating Linear tool — they are deliberately absent fromallowed-tools. - Never write, rename, or delete anything outside
01 Projects/Linear/<Project>/. - Never delete a note.
- Never change bytes at or after
<!-- linear:end -->. - Never run
gitin the vault. - Never edit notes with
Edit/Write— every write goes through the script. - Never guess a marker boundary.
- Never use
prettier --parser markdown. - Never mirror attachments other than GitHub PR/commit links.
- Never print an API key, and never search the keychain for one.