NAI OS
One issue, one subagent, one comment — and one PDF added to a library that lives outside this repo, over the Google Drive API.
You type /nai-paper-ingest <issue-number-or-url>. The command will
“Read one GitHub issue on this repo, check whether it links to a research paper (or to a
web page on a short list of sources worth keeping as they are), and if so file that document into
the personal research library for the next library-wiki batch ingest to pick
up.” That is the whole job: “This command never summarizes or tags the paper
itself — that stays library-wiki's own job, run separately.”
The orchestrator does the cheap detection itself; everything that touches the network or the
library happens in paper-ingester, which “Resolves a candidate link from a
GitHub issue to its actual PDF (direct link, arXiv abstract-page transform, or one hop via a landing
page), or renders a listed web page to PDF with a headless browser, downloads it, and files it into
the right work/<Topic>/ folder of the personal research library via the Google Drive API.
Never writes to the wiki itself — that stays library-wiki's own batch-ingestion job.”
%%{init: {'theme':'neutral', 'flowchart':{'htmlLabels':false,'nodeSpacing':45,'rankSpacing':55}}}%%
flowchart TD
H["Human types<br/>/nai-paper-ingest <issue-number-or-url>"] --> C
subgraph L1 ["1 - Orchestrator (.claude/commands)"]
C["/nai-paper-ingest<br/>resolve the number, gh issue view,<br/>detect candidates (paper class before web-source class)"]
end
C -->|"no number given / issue not found"| E1["STOP — ask the user"]
C -->|"no candidate of either class"| E2["STOP — no subagent dispatched,<br/>no comment posted"]
C -->|"paper URLs + web-source URLs"| A
subgraph L2 ["2 - Subagent (.claude/agents)"]
A["paper-ingester<br/>topics once, then per URL:<br/>resolve or render → download → read → route → dedupe → upload"]
end
A --> T1
A --> T2
A --> T3
subgraph L3 ["3 - Tools (the only route out)"]
T1["Bash: curl download, render.mjs,<br/>drive.mjs, temp-file cleanup"]
T2["WebFetch: one landing page's<br/>PDF link, nothing broader"]
T3["Read: pages 1-2 of the PDF"]
end
T1 ==> PLACE["work/<Topic>/<stem>.pdf<br/>Google Drive API, outside this repo"]
A -.->|"one line per URL"| C
A -.->|"a single blocked: line"| CH["relayed in chat only,<br/>never posted to the issue"]
C ==> CMT["ONE gh issue comment<br/>never closes, labels, or edits the issue"]
C -.->|"last, never blocking"| LED["record-run.mjs<br/>one run instance in nai-onto"]
classDef orch fill:#dcefe9,stroke:#0f6f63,stroke-width:2px
classDef tool fill:#f4f1ea,stroke:#8a7a5a
classDef human fill:#fdf3e0,stroke:#8a5a00,stroke-width:2px
classDef agent fill:#fff,stroke:#555
class C orch
class T1,T2,T3 tool
class H,E1,E2,CH,CMT,PLACE,LED human
class A agent
Bash is allowed to run — never through a filesystem path.Step 0 takes the argument in either form: “$ARGUMENTS is a bare issue
number or a pasted issue URL. Extract the number with /issues/(\d+) if a URL
was given. Neither form present → STOP and ask the user for an issue number or URL.”
Step 1 fetches it with gh issue view <n> --json number,title,body,url,state, and
“Issue not found → STOP and tell the user the number didn't resolve; ask them to
check it.”
Step 2 scans the body for URLs. “Two classes of candidate exist; check them in this order, so a direct PDF on a web-source host is a paper candidate, never a web source.”
| Class | Test, verbatim from the command file |
|---|---|
| paper candidate | “it ends in .pdf (optionally followed by a query string or fragment), or”
“its host is one of (tunable — edit this list): arxiv.org, doi.org,
dl.acm.org, ieeexplore.ieee.org, link.springer.com,
openreview.net, semanticscholar.org, biorxiv.org,
ssrn.com, researchgate.net, nature.com,
sciencedirect.com, pubmed.ncbi.nlm.nih.gov.” |
| web-source candidate | “Otherwise a URL is a web-source candidate if its host (with or without
www.) is one of (tunable — edit this list; the page itself is rendered to PDF and
filed as it is): ihk-muenchen.de.” |
The web-source list is only half of that gate: “A web-source host must also be allowed
in the cloud environment's network access before a scheduled run can reach it
(docs/machine-setup.md, research library section); the list here is the second
gate, so adding a host means both.” And when the scan comes up empty:
“No candidate of either class found → STOP. Tell the user "no
paper link found in issue #<n>" and go no further — no subagent dispatched, no
comment posted.”
The tools: column is copied exactly from the agent file's frontmatter. The command
file's step 3 names it as the dispatch: “Invoke the Agent tool for
paper-ingester with the issue number, its title, the list of paper candidate URLs,
and the list of web-source candidate URLs found in step 2 (either list may be empty, not
both).” It is told to “resolve, download or render, and file each one
independently; it does not stop early just because one candidate fails.”
| Subagent | tools: grant | What it does |
|---|---|---|
| paper-ingester | Bash, Read, WebFetch | “Resolves a candidate link from a GitHub issue to its actual PDF (direct link, arXiv abstract-page transform, or one hop via a landing page), or renders a listed web page to PDF with a headless browser, downloads it, and files it into the right work/<Topic>/ folder of the personal research library via the Google Drive API. Never writes to the wiki itself — that stays library-wiki's own batch-ingestion job.” |
Each of the three tools is granted for a named purpose and nothing else. Bash
“is granted for exactly four purposes: curl-downloading a resolved PDF
URL to a system temp path, running the render helper node scripts/paper-upload/render.mjs
(below) for a web-source candidate, running the Drive helper
node scripts/paper-upload/drive.mjs (below), and cleanup of the temp file
afterward.” WebFetch “is granted for exactly one purpose: fetching
a candidate landing page to look for the PDF link it contains. Not for general research, not for
verifying claims, not for anything beyond finding that one link.” Read opens
the downloaded file “with pages: "1-2" — the title,
authors, and abstract are normally on the first page or two; you don't need the whole
document.” The issue's own title travels along as “context only — used for
the topic-inference fallback in step 4 below, never as a source of the PDF itself”.
The library “lives in a Google Drive shared drive (the same one the
H:\Geteilte Ablagen\knowledge\ mount on the user's own machine points at); reach it
over the Drive API, not the filesystem. Its work/ folder has Drive file id
1C_wUiLmoWguZaI0f56MjaaJNINoSiZyG — that is where you place files, by
parentId.” The agent reaches it through two Node scripts, each of which
prints one line and always exits 0.
| Helper | Call | Prints, verbatim from the agent file |
|---|---|---|
| render helper | node scripts/paper-upload/render.mjs "<url>" <temp-path>.pdf |
“OK {"path","title","host","stem","pdfLinks":[...],"size"} —
stem is the Windows-safe <Title>_<host> filename stem,
pdfLinks the .pdf links found on the page — or
ERROR: <message>.” |
| Drive helper | node scripts/paper-upload/drive.mjs topics |
“OK [{"id","name"}, ...], the Topic subfolders of work/ as they
exist right now.” |
| Drive helper | node scripts/paper-upload/drive.mjs list <topicId> |
“OK [{"id","name"}, ...], the files already in one Topic folder.” |
| Drive helper | node scripts/paper-upload/drive.mjs upload <temp-path> <topicId> "<stem>.pdf" |
“OK {"id","name","size","path"} or DUPLICATE <existing name>.” |
The render helper “is your only way of turning a web page into a PDF. It fetches the
page once (and refuses anything that is not a 2xx HTML answer, so a 403 or 404 page is never printed
as if it were the source), then prints it with a headless Chromium-family browser to the temp path
you name.” The Drive helper “is your only way into the library. It
authenticates with the GDRIVE_SA_JSON service account, streams the PDF from
disk (so file size does not matter, and the bytes stay out of your own output), and prints one line,
always exit 0”.
The boundary is enforced in two places. In the helper's code: “it only lists or writes
work/'s direct children (it refuses work/ itself,
library-wiki/, wiki/, and any nested folder), only adds new files (never
overwrites, moves, or deletes), and refuses a non-PDF or a name with characters Windows cannot
hold.” And in the agent's own instructions, which refuse to lean on that: “Treat that
as a backstop, not permission: never pass a topicId you did not just read from a
topics call in this same run, and never pass the library-wiki/ folder (id
1yU3LLBwkh8fVY4wDGDxotCihGAs2lRq7) or its wiki/ subfolder (id
12aT-CTPXq0DQwjm3o0gCec54LQ2z-gmm).”
Before any download, the environment is checked once. If “The helper's
topics call prints ERROR: ... (for example
GDRIVE_SA_JSON must be set, a token failure, or a Drive permission error) → STOP
entirely and report blocked: work folder (Drive id 1C_wUiLmoWguZaI0f56MjaaJNINoSiZyG) not
reachable via Google Drive API: <the ERROR text>.” The order is
deliberate: “Make the topics call once, before the first download,
so a broken environment costs no downloads.”
| Step | Paper candidate | Web-source candidate |
|---|---|---|
| Resolve / render | “Matches arxiv.org/abs/<id> (with or without a version suffix) →
construct https://arxiv.org/pdf/<id> directly. No fetch needed for this
case.” An existing arxiv.org/pdf/<id> link is used “directly
as-is. No fetch needed for this case either.” Anything else is one WebFetch of
the landing page: “Found → use it. Not found → report
unresolved: no PDF link on landing page — <url> and move to the next
candidate. Never invent a plausible-looking PDF URL that wasn't actually present.” |
“node scripts/paper-upload/render.mjs "<url>" <temp-path>.pdf to a
system temp path (e.g. via mktemp, with .pdf appended).
ERROR: <message> → report download failed: <message> —
<url>, move to the next candidate.” |
| Download / read | “Non-zero exit, an empty file, or a file that doesn't start with %PDF (a
paywall/error page saved as if it were the PDF) → report download failed:
<url>, move to the next candidate.” A PDF with no text layer is not a failure:
“fall back to the issue's own title/body text for step 4, and say so plainly in your return
line for this paper (filed (topic inferred from issue text): ...).” |
“Some sites serve their "not found" or "access denied" page with status
200, which the helper's status check cannot catch: if the title or the first page reads as an error
page rather than the article (for example "not found", "nicht gefunden",
"access denied", "403", "404") → report download failed:
error page — <url>, delete the temp file, move to the next candidate. A cookie
banner printed above the article is not an error; ignore it.” |
| Route | “Use the topics list from the helper (fetched once per run, see
Missing input) as the real Topic folder names and ids; do not hardcode a list, new topics can
appear over time.” “No existing folder is even a rough fit → report
unfiled: no topic match — <url>, delete the temp file, move to the next
candidate. Do not create a new top-level folder — that is the user's call, not yours.” | |
| De-duplicate | “node scripts/paper-upload/drive.mjs list <topic-id> and
check the returned names for one that already matches this paper (same arXiv id substring, or a
near-identical title). Match found → report duplicate: <existing filename> —
<url>, delete the temp file, move to the next candidate without uploading
anything.” | |
| Name and place | “Derive the filename stem <Title>_<short id> (no extension yet)
from the extracted title plus the arXiv id / source id when you have one, sanitized for characters
Windows filenames can't contain (\ / : * ? " < > | → strip or replace with a
space/hyphen) — the library is still shared with a Windows-mounted view of the same Drive, so
keep names portable.” |
“Use the helper's stem as-is (<Title>_<host>, already
Windows-safe).” The page is the source, its links are not: “The page itself is the
source; the PDFs it links to are reported, not filed — which of them deserve their own issue
is the user's call.” “When pdfLinks is non-empty, follow that line with one
indented line also linked on the page (<n> PDFs): and then one indented line
per link, at most 20; past that, one more line … and <k> more.” |
| Enforced in | The rule, verbatim |
|---|---|
| .claude/commands/nai-paper-ingest.md | “No candidate of either class found → STOP. Tell the user "no paper link found in issue #<n>" and go no further — no subagent dispatched, no comment posted.” |
| .claude/commands/nai-paper-ingest.md | “If the subagent's entire return is a single blocked: ... line, that is an
environment problem (e.g. the library's work/ folder isn't reachable via the Google
Drive API right now), not something about this specific paper or issue. Do not post it as
an issue comment — relay it to the user in chat only, and stop here.” |
| .claude/commands/nai-paper-ingest.md | “Compose ONE comment summarizing all of them (not one comment per paper) and post it:” |
| .claude/commands/nai-paper-ingest.md | “Never close the issue, never add/remove a label, never edit its title or body — this comment is the only change you make to the issue.” |
| .claude/commands/nai-paper-ingest.md | “Read the printed line: OK — mention nothing; UNCONFIGURED
— the ledger is not set up on this machine, say nothing and move on; ERROR —
tell the user in one line, never retry, never let it change the report above.” |
| .claude/agents/paper-ingester.md | “You may ONLY ADD NEW FILES to the one remote location named in this section. You must NEVER change, move, overwrite, or delete anything already in that system, and never write anywhere else in it. You write no files inside this repo.” |
| .claude/agents/paper-ingester.md | “Never use it for anything else: no arbitrary shell, no touching any file this process didn't itself just create, and never for reaching the library over a local filesystem path. This library is Drive-API-only, on every machine.” |
| .claude/agents/paper-ingester.md | “You never write to wiki/ under the library. You never edit or delete an
existing file under work/.” |
| .claude/agents/paper-ingester.md | “You never fabricate a PDF URL, a topic, or a title you did not actually observe; every uncertainty is reported plainly, never guessed past.” |
| .claude/agents/paper-ingester.md | “Zero candidate URLs in both lists — this should not happen (the orchestrator only
dispatches you once it found at least one), but if it does, return unresolved: no candidate
URLs given and do nothing else.” |
Inside this repo, nothing. The agent file's output section is one sentence long on that point:
“Nothing written inside this repo. The only change is the one .pdf
uploaded into the matching Topic subfolder of work/ (Drive id
1C_wUiLmoWguZaI0f56MjaaJNINoSiZyG) per successfully filed candidate — plus the
local temp file, which is always deleted before you return, win or lose.”
Back to the orchestrator goes “One line per candidate URL, in the exact vocabulary used
above:” filed: work/<Topic>/<stem>.pdf |
filed (web page): work/<Topic>/<stem>.pdf |
duplicate: <existing filename> — <url> |
unresolved: no PDF link on landing page — <url> |
download failed: <url> | download failed: <message> — <url> |
unfiled: no topic match — <url> |
upload failed: <message> — <url>. The one exception: “If you
hit the blocked: case in Missing input instead (the work/
folder itself unreachable), that one stops the whole run: return only that line.”
The orchestrator turns those lines into one gh issue comment <n> --body
"<summary>". The command file's example starts “Paper ingest:”
and lists “- filed: work/AI/Smith_Attention_2401.12345.pdf”; a filed web
page carries its linked PDFs beneath it, and “The also linked on the page
list is informational: those PDFs were not downloaded, and the user decides which of them
deserve an issue of their own.” The chat report is the same text: “Tell the user
the same summary shown in the comment, plus the comment's URL.”
Last, and never blocking: “After the report, record this run in the NAI OS ontology
(udopnink/nai-onto, ontologies/nai-os, class
run) with one Bash call:”
node scripts/onto-sync/record-run.mjs nai-paper-ingest "<issue-number>" {outcome}.
See the research library concept page for the library
this system writes into.