feat(skills/linear): add Documents support + Python helper script (#20752)

* feat(skills/linear): add Documents support + Python helper script

The bundled Linear skill (PR #1230) covered issues, projects, teams, and
workflow states via curl. It had no coverage for Linear's Documents API,
so fetching an RFC/doc from a linear.app URL required hand-writing
GraphQL against an underdocumented schema.

Adds:
- Documents section in SKILL.md explaining slugId extraction from URLs,
  the contentState (markdown) vs contentState (ProseMirror) split, and
  four canonical curl examples (fetch by slugId, fetch by UUID, list
  recent, title-search).
- scripts/linear_api.py — stdlib-only Python CLI wrapping the most
  common operations (whoami, list-teams, list/get/search/create/update
  issues, add-comment, update-status, list/get/search documents, raw
  GraphQL passthrough). Zero deps, reads LINEAR_API_KEY from env.

Auth header quirk (personal key takes bare $LINEAR_API_KEY, no Bearer
prefix) is already documented in the skill.

Found during RFC review: the existing skill's lack of document support
forced falling back to the browser (which hit Linear's login wall).
Also fixes a schema gotcha — the Document field is `contentState`, not
`contentData` (which returns 400).

Tested end-to-end against the production API:
  python3 linear_api.py whoami
  python3 linear_api.py get-document 38359beef67c
Both return expected payloads.

* fix(skills/linear): point LINEAR_API_KEY setup to the correct page

The org-level Settings > API page (/settings/api) only shows OAuth apps
and workspace-member keys. Personal API keys live under Account,
Security, access (/settings/account/security). Update both the setup
link in config.py (shown during hermes setup) and the setup step in
SKILL.md so users land on the page that can create a personal key.
This commit is contained in:
Teknium 2026-05-06 08:27:21 -07:00 committed by GitHub
parent b62a82e0c3
commit ad7aad251c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 612 additions and 3 deletions

View file

@ -1944,7 +1944,7 @@ OPTIONAL_ENV_VARS = {
"LINEAR_API_KEY": {
"description": "Linear personal API key (used by the `linear` skill)",
"prompt": "Linear API key",
"url": "https://linear.app/settings/api",
"url": "https://linear.app/settings/account/security",
"password": True,
"category": "skill",
"advanced": True,

View file

@ -18,7 +18,7 @@ Manage Linear issues, projects, and teams directly via the GraphQL API using `cu
## Setup
1. Get a personal API key from **Linear Settings > API > Personal API keys**
1. Get a personal API key from **Linear Settings > Account > Security & access > Personal API keys** (URL: https://linear.app/settings/account/security). Note: the org-level *Settings > API* page only shows OAuth apps and workspace-member keys, not personal keys.
2. Set `LINEAR_API_KEY` in your environment (via `hermes setup` or your env config)
## API Basics
@ -36,6 +36,24 @@ curl -s -X POST https://api.linear.app/graphql \
-d '{"query": "{ viewer { id name } }"}' | python3 -m json.tool
```
## Python helper script (ergonomic alternative)
For faster one-liners that don't need hand-written GraphQL, this skill ships a stdlib Python CLI at `scripts/linear_api.py`. Zero dependencies. Same auth (reads `LINEAR_API_KEY`).
```bash
SCRIPT=$(dirname "$(find ~/.hermes -path '*skills/productivity/linear/scripts/linear_api.py' 2>/dev/null | head -1)")/linear_api.py
python3 "$SCRIPT" whoami
python3 "$SCRIPT" list-teams
python3 "$SCRIPT" get-issue ENG-42
python3 "$SCRIPT" get-document 38359beef67c # fetch a doc by slugId from the URL
python3 "$SCRIPT" raw 'query { viewer { name } }'
```
All subcommands: `whoami`, `list-teams`, `list-projects`, `list-states`, `list-issues`, `get-issue`, `search-issues`, `create-issue`, `update-issue`, `update-status`, `add-comment`, `list-documents`, `get-document`, `search-documents`, `raw`. Run with `--help` for flags.
Use the script when: you want a quick answer without crafting GraphQL. Use curl when: you need a query the script doesn't wrap, or you want to compose filters inline.
## Workflow States
Linear uses `WorkflowState` objects with a `type` field. **6 state types:**
@ -245,6 +263,70 @@ curl -s -X POST https://api.linear.app/graphql \
}' | python3 -m json.tool
```
## Documents
Linear **Documents** are prose docs (RFCs, specs, notes) stored alongside issues. They have their own `documents` root query and `document(id:)` single-fetch.
### Document URLs and `slugId`
Document URLs look like:
```
https://linear.app/<workspace>/document/<slug>-<hexSlugId>
```
The trailing hex segment is the `slugId`. Example: `https://linear.app/nousresearch/document/rfc-hermes-permission-gateway-discord-38359beef67c``slugId` is `38359beef67c`.
**Important schema detail:** the Markdown body is in the `content` field. The ProseMirror JSON is in `contentState` (not `contentData` — that field does not exist and the API returns 400).
### Fetch a document by slugId
`document(id:)` only accepts UUIDs. To fetch by the URL's hex slug, filter the collection:
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "query($s: String!) { documents(filter: { slugId: { eq: $s } }, first: 1) { nodes { id title content contentState slugId url creator { name } project { name } updatedAt } } }", "variables": {"s": "38359beef67c"}}' \
| python3 -m json.tool
```
Or via the Python helper:
```bash
python3 scripts/linear_api.py get-document 38359beef67c
```
### Fetch a document by UUID
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ document(id: \"11700cff-b514-4db3-afcc-3ed1afacba1c\") { title content url } }"}' \
| python3 -m json.tool
```
### List recent documents
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ documents(first: 25, orderBy: updatedAt) { nodes { id title slugId url updatedAt project { name } } } }"}' \
| python3 -m json.tool
```
### Search documents by title
Linear's schema has no `searchDocuments` root. Use a title-substring filter instead:
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ documents(filter: { title: { containsIgnoreCase: \"RFC\" } }, first: 25) { nodes { title slugId url } } }"}' \
| python3 -m json.tool
```
## Pagination
Linear uses Relay-style cursor pagination:

View file

@ -0,0 +1,445 @@
#!/usr/bin/env python3
"""Linear GraphQL API CLI — zero dependencies, stdlib only.
Usage:
linear_api.py <command> [args...]
Commands:
whoami Show authenticated user
list-teams List all teams
list-projects [--team KEY] List projects (optionally filter by team)
list-states [--team KEY] List workflow states
list-issues [filters] List issues
--team KEY Filter by team key (e.g. ENG)
--status NAME Filter by workflow state name
--assignee NAME Filter by assignee name (exact)
--label NAME Filter by label name
--limit N Max results (default: 25)
get-issue <IDENTIFIER> Full issue details (e.g. ENG-42)
search-issues <query> Full-text search across issues
create-issue [options] Create a new issue
--title TITLE Required
--team KEY Required
--description DESC
--priority 0-4 0=none, 1=urgent, 4=low
--label NAME
--assignee NAME
--parent IDENTIFIER Parent issue ID for sub-issues
update-issue <IDENTIFIER> [options] Update existing issue (same options as create)
update-status <IDENTIFIER> <STATE> Move issue to workflow state (by state name)
add-comment <IDENTIFIER> <body> Add comment to issue
list-documents [--limit N] List documents (docs, not issues)
get-document <SLUG_OR_ID> Fetch a document by slugId (from URL) or UUID
search-documents <query> Search documents by title
raw <graphql_query> [variables_json] Run an arbitrary GraphQL query
Use --vars '{"key":"value"}' for variables
Auth:
Set LINEAR_API_KEY environment variable (from Linear Settings -> API).
Uses the personal API key header format: `Authorization: <KEY>` (no Bearer prefix).
Output:
JSON to stdout. Errors to stderr with non-zero exit code.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from typing import Any
API_URL = "https://api.linear.app/graphql"
def _get_key() -> str:
key = os.environ.get("LINEAR_API_KEY", "").strip()
if not key:
sys.stderr.write(
"ERROR: LINEAR_API_KEY not set.\n"
"Create one at https://linear.app/settings/api and export it,\n"
"or add `LINEAR_API_KEY=lin_api_...` to ~/.hermes/.env\n"
)
sys.exit(2)
return key
def gql(query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
"""Execute a GraphQL query against Linear. Raises on HTTP error or GraphQL errors."""
key = _get_key()
payload = {"query": query}
if variables:
payload["variables"] = variables
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
API_URL,
data=data,
headers={
"Content-Type": "application/json",
"Authorization": key, # Personal API key — NO `Bearer` prefix
"User-Agent": "hermes-agent-linear-skill/1.0",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8")
except urllib.error.HTTPError as e:
sys.stderr.write(f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')}\n")
sys.exit(1)
except urllib.error.URLError as e:
sys.stderr.write(f"Network error: {e}\n")
sys.exit(1)
result = json.loads(body)
if "errors" in result and result["errors"]:
sys.stderr.write(f"GraphQL errors: {json.dumps(result['errors'], indent=2)}\n")
# Still return data if partial success; let caller decide
if not result.get("data"):
sys.exit(1)
return result.get("data", {}) or {}
def emit(obj: Any) -> None:
print(json.dumps(obj, indent=2, default=str))
# ---------- Commands ----------
def cmd_whoami(_args: argparse.Namespace) -> None:
q = "query { viewer { id name email displayName } }"
emit(gql(q).get("viewer"))
def cmd_list_teams(_args: argparse.Namespace) -> None:
q = "query { teams(first: 100) { nodes { id key name description } } }"
emit(gql(q).get("teams", {}).get("nodes", []))
def _resolve_team_id(key_or_name: str) -> str | None:
"""Map a team key (ENG) or name to UUID."""
q = "query { teams(first: 100) { nodes { id key name } } }"
teams = gql(q).get("teams", {}).get("nodes", [])
kl = key_or_name.lower()
for t in teams:
if t["key"].lower() == kl or t["name"].lower() == kl:
return t["id"]
return None
def cmd_list_projects(args: argparse.Namespace) -> None:
if args.team:
tid = _resolve_team_id(args.team)
if not tid:
sys.stderr.write(f"Team not found: {args.team}\n")
sys.exit(1)
q = """query($id: String!) {
team(id: $id) { projects(first: 100) { nodes { id name description state } } }
}"""
data = gql(q, {"id": tid})
emit(data.get("team", {}).get("projects", {}).get("nodes", []))
else:
q = "query { projects(first: 100) { nodes { id name description state } } }"
emit(gql(q).get("projects", {}).get("nodes", []))
def cmd_list_states(args: argparse.Namespace) -> None:
if args.team:
tid = _resolve_team_id(args.team)
if not tid:
sys.stderr.write(f"Team not found: {args.team}\n")
sys.exit(1)
q = """query($id: String!) {
team(id: $id) { states(first: 100) { nodes { id name type color } } }
}"""
emit(gql(q, {"id": tid}).get("team", {}).get("states", {}).get("nodes", []))
else:
q = "query { workflowStates(first: 200) { nodes { id name type team { key } } } }"
emit(gql(q).get("workflowStates", {}).get("nodes", []))
def cmd_list_issues(args: argparse.Namespace) -> None:
filt: dict[str, Any] = {}
if args.team:
filt["team"] = {"key": {"eq": args.team}}
if args.status:
filt["state"] = {"name": {"eq": args.status}}
if args.assignee:
filt["assignee"] = {"name": {"eq": args.assignee}}
if args.label:
filt["labels"] = {"name": {"eq": args.label}}
q = """query($filter: IssueFilter, $first: Int!) {
issues(filter: $filter, first: $first, orderBy: updatedAt) {
nodes {
id identifier title
state { name } priority
assignee { name }
team { key }
updatedAt url
}
}
}"""
data = gql(q, {"filter": filt or None, "first": args.limit})
emit(data.get("issues", {}).get("nodes", []))
def cmd_get_issue(args: argparse.Namespace) -> None:
q = """query($id: String!) {
issue(id: $id) {
id identifier title description
state { name type }
priority priorityLabel
assignee { name email }
creator { name }
team { key name }
project { name }
labels { nodes { name } }
parent { identifier title }
children { nodes { identifier title state { name } } }
comments { nodes { user { name } body createdAt } }
createdAt updatedAt url
}
}"""
emit(gql(q, {"id": args.identifier}).get("issue"))
def cmd_search_issues(args: argparse.Namespace) -> None:
q = """query($term: String!, $first: Int!) {
searchIssues(term: $term, first: $first) {
nodes { id identifier title state { name } url }
}
}"""
emit(gql(q, {"term": args.query, "first": args.limit}).get("searchIssues", {}).get("nodes", []))
def cmd_create_issue(args: argparse.Namespace) -> None:
tid = _resolve_team_id(args.team)
if not tid:
sys.stderr.write(f"Team not found: {args.team}\n")
sys.exit(1)
inp: dict[str, Any] = {"title": args.title, "teamId": tid}
if args.description:
inp["description"] = args.description
if args.priority is not None:
inp["priority"] = args.priority
if args.parent:
inp["parentId"] = args.parent
# TODO: label + assignee name->id lookup (omitted for v1 brevity)
q = """mutation($input: IssueCreateInput!) {
issueCreate(input: $input) {
success issue { id identifier title url }
}
}"""
emit(gql(q, {"input": inp}).get("issueCreate"))
def cmd_update_issue(args: argparse.Namespace) -> None:
inp: dict[str, Any] = {}
if args.title:
inp["title"] = args.title
if args.description:
inp["description"] = args.description
if args.priority is not None:
inp["priority"] = args.priority
if not inp:
sys.stderr.write("No update fields provided.\n")
sys.exit(1)
q = """mutation($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success issue { identifier title url }
}
}"""
emit(gql(q, {"id": args.identifier, "input": inp}).get("issueUpdate"))
def cmd_update_status(args: argparse.Namespace) -> None:
# Resolve state name -> id within the issue's team
get_q = """query($id: String!) {
issue(id: $id) { team { id states(first: 100) { nodes { id name } } } }
}"""
issue = gql(get_q, {"id": args.identifier}).get("issue")
if not issue:
sys.stderr.write(f"Issue not found: {args.identifier}\n")
sys.exit(1)
sl = args.state.lower()
match = next((s for s in issue["team"]["states"]["nodes"] if s["name"].lower() == sl), None)
if not match:
sys.stderr.write(
f"State '{args.state}' not found. Available: "
f"{[s['name'] for s in issue['team']['states']['nodes']]}\n"
)
sys.exit(1)
q = """mutation($id: String!, $stateId: String!) {
issueUpdate(id: $id, input: { stateId: $stateId }) {
success issue { identifier state { name } url }
}
}"""
emit(gql(q, {"id": args.identifier, "stateId": match["id"]}).get("issueUpdate"))
def cmd_add_comment(args: argparse.Namespace) -> None:
q = """mutation($input: CommentCreateInput!) {
commentCreate(input: $input) {
success comment { id body createdAt }
}
}"""
emit(gql(q, {"input": {"issueId": args.identifier, "body": args.body}}).get("commentCreate"))
# ---- Documents ----
def cmd_list_documents(args: argparse.Namespace) -> None:
q = """query($first: Int!) {
documents(first: $first, orderBy: updatedAt) {
nodes { id title slugId updatedAt url project { name } creator { name } }
}
}"""
emit(gql(q, {"first": args.limit}).get("documents", {}).get("nodes", []))
def cmd_get_document(args: argparse.Namespace) -> None:
"""Fetch a document by slugId (from URL) OR full UUID.
Linear document URLs look like:
https://linear.app/<workspace>/document/<slug>-<shortid>
The part we want is the final hex segment (the slugId).
"""
ref = args.ref
# If it looks like a UUID, query by id. Otherwise, assume slugId.
is_uuid = len(ref) == 36 and ref.count("-") == 4
if is_uuid:
q = """query($id: String!) {
document(id: $id) {
id title content contentState slugId
createdAt updatedAt url
creator { name } project { name }
}
}"""
emit(gql(q, {"id": ref}).get("document"))
else:
# Query the collection and filter by slugId — the doc() query only accepts UUIDs.
q = """query($slug: String!) {
documents(filter: { slugId: { eq: $slug } }, first: 1) {
nodes {
id title content contentState slugId
createdAt updatedAt url
creator { name } project { name }
}
}
}"""
nodes = gql(q, {"slug": ref}).get("documents", {}).get("nodes", [])
emit(nodes[0] if nodes else None)
def cmd_search_documents(args: argparse.Namespace) -> None:
# Linear doesn't have a first-class searchDocuments — use title filter as a fallback.
q = """query($term: String!, $first: Int!) {
documents(filter: { title: { containsIgnoreCase: $term } }, first: $first) {
nodes { id title slugId url updatedAt }
}
}"""
emit(gql(q, {"term": args.query, "first": args.limit}).get("documents", {}).get("nodes", []))
def cmd_raw(args: argparse.Namespace) -> None:
variables = json.loads(args.vars) if args.vars else None
emit(gql(args.query, variables))
# ---------- Arg parsing ----------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="linear_api.py", description="Linear GraphQL CLI")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("whoami").set_defaults(func=cmd_whoami)
sub.add_parser("list-teams").set_defaults(func=cmd_list_teams)
lp = sub.add_parser("list-projects")
lp.add_argument("--team")
lp.set_defaults(func=cmd_list_projects)
ls = sub.add_parser("list-states")
ls.add_argument("--team")
ls.set_defaults(func=cmd_list_states)
li = sub.add_parser("list-issues")
li.add_argument("--team")
li.add_argument("--status")
li.add_argument("--assignee")
li.add_argument("--label")
li.add_argument("--limit", type=int, default=25)
li.set_defaults(func=cmd_list_issues)
gi = sub.add_parser("get-issue")
gi.add_argument("identifier")
gi.set_defaults(func=cmd_get_issue)
si = sub.add_parser("search-issues")
si.add_argument("query")
si.add_argument("--limit", type=int, default=25)
si.set_defaults(func=cmd_search_issues)
ci = sub.add_parser("create-issue")
ci.add_argument("--title", required=True)
ci.add_argument("--team", required=True)
ci.add_argument("--description")
ci.add_argument("--priority", type=int, choices=[0, 1, 2, 3, 4])
ci.add_argument("--label")
ci.add_argument("--assignee")
ci.add_argument("--parent")
ci.set_defaults(func=cmd_create_issue)
ui = sub.add_parser("update-issue")
ui.add_argument("identifier")
ui.add_argument("--title")
ui.add_argument("--description")
ui.add_argument("--priority", type=int, choices=[0, 1, 2, 3, 4])
ui.set_defaults(func=cmd_update_issue)
us = sub.add_parser("update-status")
us.add_argument("identifier")
us.add_argument("state")
us.set_defaults(func=cmd_update_status)
ac = sub.add_parser("add-comment")
ac.add_argument("identifier")
ac.add_argument("body")
ac.set_defaults(func=cmd_add_comment)
ld = sub.add_parser("list-documents")
ld.add_argument("--limit", type=int, default=50)
ld.set_defaults(func=cmd_list_documents)
gd = sub.add_parser("get-document")
gd.add_argument("ref", help="slugId (hex suffix from URL) or full UUID")
gd.set_defaults(func=cmd_get_document)
sd = sub.add_parser("search-documents")
sd.add_argument("query")
sd.add_argument("--limit", type=int, default=25)
sd.set_defaults(func=cmd_search_documents)
r = sub.add_parser("raw")
r.add_argument("query")
r.add_argument("--vars", help="JSON string of variables")
r.set_defaults(func=cmd_raw)
return p
def main(argv: list[str] | None = None) -> None:
parser = build_parser()
args = parser.parse_args(argv)
args.func(args)
if __name__ == "__main__":
main()

View file

@ -33,7 +33,7 @@ Manage Linear issues, projects, and teams directly via the GraphQL API using `cu
## Setup
1. Get a personal API key from **Linear Settings > API > Personal API keys**
1. Get a personal API key from **Linear Settings > Account > Security & access > Personal API keys** (URL: https://linear.app/settings/account/security). Note: the org-level *Settings > API* page only shows OAuth apps and workspace-member keys, not personal keys.
2. Set `LINEAR_API_KEY` in your environment (via `hermes setup` or your env config)
## API Basics
@ -51,6 +51,24 @@ curl -s -X POST https://api.linear.app/graphql \
-d '{"query": "{ viewer { id name } }"}' | python3 -m json.tool
```
## Python helper script (ergonomic alternative)
For faster one-liners that don't need hand-written GraphQL, this skill ships a stdlib Python CLI at `scripts/linear_api.py`. Zero dependencies. Same auth (reads `LINEAR_API_KEY`).
```bash
SCRIPT=$(dirname "$(find ~/.hermes -path '*skills/productivity/linear/scripts/linear_api.py' 2>/dev/null | head -1)")/linear_api.py
python3 "$SCRIPT" whoami
python3 "$SCRIPT" list-teams
python3 "$SCRIPT" get-issue ENG-42
python3 "$SCRIPT" get-document 38359beef67c # fetch a doc by slugId from the URL
python3 "$SCRIPT" raw 'query { viewer { name } }'
```
All subcommands: `whoami`, `list-teams`, `list-projects`, `list-states`, `list-issues`, `get-issue`, `search-issues`, `create-issue`, `update-issue`, `update-status`, `add-comment`, `list-documents`, `get-document`, `search-documents`, `raw`. Run with `--help` for flags.
Use the script when: you want a quick answer without crafting GraphQL. Use curl when: you need a query the script doesn't wrap, or you want to compose filters inline.
## Workflow States
Linear uses `WorkflowState` objects with a `type` field. **6 state types:**
@ -260,6 +278,70 @@ curl -s -X POST https://api.linear.app/graphql \
}' | python3 -m json.tool
```
## Documents
Linear **Documents** are prose docs (RFCs, specs, notes) stored alongside issues. They have their own `documents` root query and `document(id:)` single-fetch.
### Document URLs and `slugId`
Document URLs look like:
```
https://linear.app/<workspace>/document/<slug>-<hexSlugId>
```
The trailing hex segment is the `slugId`. Example: `https://linear.app/nousresearch/document/rfc-hermes-permission-gateway-discord-38359beef67c``slugId` is `38359beef67c`.
**Important schema detail:** the Markdown body is in the `content` field. The ProseMirror JSON is in `contentState` (not `contentData` — that field does not exist and the API returns 400).
### Fetch a document by slugId
`document(id:)` only accepts UUIDs. To fetch by the URL's hex slug, filter the collection:
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "query($s: String!) { documents(filter: { slugId: { eq: $s } }, first: 1) { nodes { id title content contentState slugId url creator { name } project { name } updatedAt } } }", "variables": {"s": "38359beef67c"}}' \
| python3 -m json.tool
```
Or via the Python helper:
```bash
python3 scripts/linear_api.py get-document 38359beef67c
```
### Fetch a document by UUID
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ document(id: \"11700cff-b514-4db3-afcc-3ed1afacba1c\") { title content url } }"}' \
| python3 -m json.tool
```
### List recent documents
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ documents(first: 25, orderBy: updatedAt) { nodes { id title slugId url updatedAt project { name } } } }"}' \
| python3 -m json.tool
```
### Search documents by title
Linear's schema has no `searchDocuments` root. Use a title-substring filter instead:
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ documents(filter: { title: { containsIgnoreCase: \"RFC\" } }, first: 25) { nodes { title slugId url } } }"}' \
| python3 -m json.tool
```
## Pagination
Linear uses Relay-style cursor pagination: