Research reports
Joshu researches a topic on the web, checks each fact against its page, and sends a PDF, and this page explains the pipeline and recurring reports.
Technical reference: all pages
Purpose
A Research report tells the owner what good web sources say about one topic: news, reports, research papers, company pages and filings, and official sources, with the recent pages first. Each fact links to the page that it comes from, theme sections have a picture from a source when one fits, and a small box shows the social posts with the most attention. The owner asks the companion on any channel, and the companion answers in the same conversation with a short summary and a PDF. The box saves each Research report as a PDF, an HTML page, and a data file in the folder Research of the owner files.
A recurring report follows one or more topics (lanes) on a schedule. Each run groups what web, news, papers, company data, and social sources published into stories, compares them with the stories of earlier runs, and sends a report update with only the new and developing stories, written the way the owner asked. A repeat request on the topic of an earlier Research report gives a report update against that report instead of a new report. The Research app on the desktop lists every report and has one form for a new one.
Motivation
Before Research reports, research ran in the last30days desktop app. Its result was a list of grouped posts inside the app, or a chat answer and a Markdown file, and the owner could not forward either one to a client. The async reply of the app tool reached only jChat, because Joshu dropped the text of the follow-up Hermes turn, so a request by text message, Slack, or Telegram got no answer.
These constraints shaped the design:
- Owners read on many channels and forward what they get, so the report must stand alone: fixed fonts, no script, and nothing loaded from the internet.
- A model can invent facts, numbers, and links, so the facts of the report must come from the pages that the box read, and code must check what the model writes.
- A first version that used the last30days engine as the only collector gave mostly social posts and reactions. The owner wants a research report from good sources, with social posts as a small extra.
- The sources will change. The parts that search and read pages must be replaceable without a change to the writer or the renderer.
- A request on a text message, Slack, or Telegram already goes to a background worker with a long time limit, and jChat has no turn time limit, so a synchronous call can serve every channel.
- An MCP tool call times out after 300 seconds, and a Research report can take several minutes.
Recurring reports replaced the Watch feature of the last30days app, which refreshed watched topics but had no comparison between runs, so one box had a custom Hermes cron job that rewrote the whole result every weekday. Owners also follow a topic across many kinds of sources, and checking each source by hand is the work that a companion should do. These constraints shaped recurring reports:
- A Hermes cron job cannot send a text message, and agent cron jobs have hung on a full gateway pipe and were skipped after a model switch. A run must not depend on a Hermes turn.
- Two runs must search the same way, or a story looks new or quiet only because the queries changed.
- The comparison must be in plain files that the next run, the companion, the Research app, and gbrain can read.
- The box should hold no code of another project: the last30days engine needed Python 3.12, a pinned copy, and four patches.
Architecture
flowchart LR
Owner -->|any channel| Companion[Companion<br/>skill research-report]
Companion -->|research_report_make| Plugin[Hermes plugin<br/>joshu-research]
Plugin -->|POST /api/research/reports| Make[makeResearchReport<br/>research pipeline]
Make --> Store[(Research/<id>/<br/>report.pdf .html .json)]
Companion -->|summary + report link| Outbox[Owner outbox]
Outbox -->|upload PDF| SlackTelegram[Slack, Telegram]
Outbox -->|text with link| SMS
Phone[Phone browser] -->|report link| Link[/api/research/r/:id/:sig/report.pdf/]
Link --> Store
Companion -->|research_recurring_save| Plugin
App[Research app] -->|/api/research/recurring| Service[Recurring report service]
Plugin -->|/api/research/recurring| Service
Service -->|no-agent job| Cron[Hermes cron job]
Cron -->|POST /recurring/:id/run| Run[Run: collect, group,<br/>compare, write, check]
Run --> Folder[(Research/Recurring/<slug>/<br/>setup, stories, updates)]
Run -->|email, text, Slack, Telegram| Owner
The diagram above shows how a request reaches the box and how the Research report goes back to the owner. The next diagram shows what happens inside makeResearchReport: how the information moves from the topic of the owner to the parts of the PDF. The source file of the diagram names the code of each box.
The source of this diagram is diagrams/research-reports.json (an Archify dataflow diagram).
The next diagram shows one run of a recurring report: the Hermes cron job starts the run, the collect step searches the sources of each lane with its lane plan, the group and compare steps make the stories and give each one its story status against stories.json, and the writer and the check make the digest that Joshu saves and sends.
The source of this diagram is diagrams/research-reports-recurring.json (an Archify dataflow diagram).
The parts and what each one owns:
- The research pipeline (
src/research/pipeline/) makes the research.llm.tsis the shared OpenRouter call,exa.tsthe Exa client,composioSearch.tsthe Composio Search client, andplan.ts,search.ts,company.ts,extract.ts,write.ts, andpictures.tsare the steps. - The social sources (
src/research/social/) search Reddit, YouTube, TikTok, and Instagram through the ScrapeCreators relay and X through the Xquik relay of the control plane (or the vendor APIs with a key on a self-host box), and pick the posts of the social box with one model call. - The recurring report service (
src/research/recurring/service.ts) makes, changes, pauses, and deletes recurring reports and their Hermes cron jobs, and plans each new lane (plan.ts). The store (store.ts) owns the folders in<files>/Research/Recurring/. - A run (
run.ts) collects (collect.ts), groups and compares stories (stories.ts), writes and checks the digest (digest.ts), saves the report update, and sends it (delivery.ts). The runner inrun.tsstarts runs in the background, at most two at once. - Report updates on request (
repeat.ts) and the PDF of the full update (updatePdf.ts) reuse the steps of a run. - The Watch migration (
watchMigration.ts) moves the watched topics of the last30days app to one recurring report at the first boot. - The Research app (
apps/research/) and the routes of recurring reports (src/research/recurring/routes.ts) use the same service as the plugin tools. - The charts (
src/research/charts.ts) count items by day, by source, and by theme, and draw two SVG charts. The first report has no charts; the code stays for the follow-up report. - The template (
src/research/template.ts) renders the HTML page with the fonts fromsrc/research/fonts.ts. - The PDF render (
src/research/pdf.ts) prints the HTML page with a one-shot headless Chromium run. - The report store (
src/research/store.ts) owns the folders in<files>/Research/and finds earlier Research reports. - The report links (
src/research/reportLinks.ts) sign and check the public links, and find report links in outbox texts. - The routes (
src/research/routes.ts) serve the plugin on localhost and the public report link. - The Hermes plugin
joshu-researchand the skillresearch-reportconnect the companion to the routes.
Theory of operation
A Research report goes through these steps:
- The owner asks for research. On a text message, a phone call, Slack, or Telegram, the realtime-goal broker acknowledges the request and queues a worker. On email, the
ea-owner-replyworker runs. In jChat, the companion writes one line that the report started. - The companion asks one question first only when the topic can mean more than one thing.
- The companion calls
research_report_makewith the topic, and with the notes and the sections of the owner when the owner gave them. The notes say what the topic means, what it is not, and what to look at. The sections are at most 6 names, in the owner's order. The Research app sends the same fields from its form. The plugin posts to the Joshu API and waits up to 11 minutes. - After the plan step, the social box starts and runs in parallel with the search and the fact extraction. It searches the social sources for the last 30 days with the short keyword query of the plan, sends the 40 posts with the most engagement (at most 8 from one source) to one model call that picks the posts about the topic, and keeps the five with the most engagement, in each source's own unit, with at most two from one source. When it fails, is late, or finds no relevant post, the report has no social box.
- The plan step asks the model for a research brief, one sentence on what the topic means (
about), up to four things with a similar name that the report is not about (notAbout), and four to six sub-questions, each with a recent query and an evergreen query. The model follows the notes of the owner. When the owner gave sections, the plan has exactly one sub-question for each section, in the owner's order, and each sub-question keeps its section. The model also gives a Google Scholar query for at most two sub-questions that ask what research shows, and names the company with its ticker and exchange when the topic is about one public company. When the model fails, a fixed plan of five general sub-questions is used, with no Google Scholar query and no company, or one sub-question for each owner section. - The search step runs two Exa searches for each sub-question, at most four at once. The recent query only finds pages from the last 30 days. Both exclude social sites and content farms. When the box has a Composio key, Composio Search runs at the same time: a Google News search of the last month with the recent query of each sub-question, and a Google Scholar search for each Google Scholar query. Code removes duplicate pages, keeps at most two pages for each site, ranks the Exa pages by search rank, highlight score, and how many searches found them, and keeps 24, chosen from the Exa results alone. On top of them it adds at most 6 news articles that are not among the Exa pages and the top paper of each Google Scholar search. Exa then returns the full text (12,000 characters) and the images of the best 12 Exa pages, the best 3 added news articles, and the top papers, because the snippet of a paper is too short for facts. A news or paper hit has no highlight score, so it gets a fixed score of 0.3, which only orders the pages. When every Exa search fails, the report fails at step "search". A Composio search that fails only gives fewer pages.
- When the plan names a company, the company step gets the daily stock prices of the last month from Google Finance and the newest three SEC filings (10-K, 10-Q, or 8-K), at the same time as the searches. Code writes one price page with computed lines (first and last close, change, highest and lowest close) and one line for each trading day. Exa reads each filing for the passages that match the research brief. The price page and the filing pages come first in the list of pages and every sub-question reads them, outside the limit of 24. When the company name from SEC or Google Finance shares no word with the company of the plan, code drops both.
- The extract step asks the model, once for each sub-question and in parallel, for facts with an exact quote from a page. The call gets
aboutandnotAbout, and skips each fact about a thing with a similar name, also when the page itself mixes the two up. For a page whose full text is shorter than 1,000 characters (often a paywall), the model reads the passages from the search first. Code keeps a fact only when its quote is in the text of its page, removes repeated quotes, and numbers the factsF1,F2, and so on. - The write step asks the model for an outline (three to six theme sections and four or five findings, each with fact identifiers), and then for the whole text in one call, with each fact cited as
[F12]. With owner sections, the outline has those sections in the owner's order with the owner's words as labels, a section needs one fact instead of two, and a section with no fact is left out. When the outline call fails, each owner section gets the facts of its sub-question. Both calls getaboutandnotAbout. Code drops each sentence that cites an unknown fact, and each sentence with a number that is not in the quote of a fact it cites. With fewer than 8 facts, the report is the short version: it lists the facts instead of themes. - The pictures step takes, for each theme section, the first cited page with a suitable picture, downloads it, checks its type, width, and size, and embeds it as a data URI with the site name and a link to the page.
- The HTML page and the PDF are made in a temporary folder inside
Research/. The folder is renamed to<YYYY-MM-DD>-<topic slug>(with-2,-3for a repeat on the same day) only when every step worked. A failure leaves no folder. - The tool returns the summary, the report link, the PDF path relative to the Desktop (for mail attachments), the PDF path relative to the owner files (for
desktop_open), and the source states. On a failure it returnsok: falsewith the step that failed. - The companion answers on the channel of the request. In email, it replies on the thread with the PDF attached. In jChat, it gives the summary and the link, and opens the PDF on the desktop. On the other channels, the worker completes its task with the summary and the report link on the last line, and the owner outbox sends it. The Slack and Telegram senders find the report link in the text and upload the PDF with it. A phone result follows the rules of the owner outbox: a callback speaks the summary and texts the link, or a text message carries both when the box does not call.
A run of a recurring report goes through these steps:
- The Hermes cron job of the recurring report (
no_agent: true) runs its script at the scheduled time in the owner timezone. The script posts to/joshu/api/research/recurring/<id>/runon localhost, and Joshu answers 202 and runs in the background. A recurring report that is still running is skipped, and a third run waits for one of two to end. - Collect (2 minutes, no model): for each lane, the queries of its lane plan on its sources, for the period since the last run that worked (a run that sent a report update or found nothing new), or the last 7 days for a starting update and a new lane. Web pages come from Exa (index and category pages are left out) and Google News (each redirect link resolved to the article link, so that the same article has the same link on every run), papers from Google Scholar, a stock price move of at least 5% and new SEC filings from Composio Search, and posts from the social sources (the 10 with the most engagement of each source). An item that two lanes found stays in the first lane. When every Exa search fails, the run fails.
- Group (90 seconds): one model call for each lane, in parallel, groups the items into stories and leaves out the items that are not about the lane. The call gets what the topic of the lane means and what it is not, from the lane plan, and the notes of the owner, so it also leaves out items about a thing with a similar name.
- Compare (60 seconds): a story with a link of a stored story joins that story with no model call. One model call matches the other stories with the stored stories of the last 60 days, and with each other across lanes. A story is
new(no stored story),developing(a stored story, and new items), orquiet(a stored story, and no new item). A story of a starting lane is always new. - Write and check (2 minutes): the writer gets the notes, the channel format, and the best new and developing stories (the best of each lane first, at most 6 from one lane and 24 in all; when the notes name a number of items, only that many stories, the ones with the most weight across all lanes), and cites stories as
[S3]. Code drops each sentence with an unknown story or a number that is not in an item of the stories of its line, and replaces each citation with the links of its story (a direct link before a Google News redirect). - Save and send (60 seconds):
updates/<YYYY-MM-DD-HHMM>/update.jsonanddigest.md, then the delivery on the channel of the recurring report, thenstories.json. A run with nothing new savesupdate.jsononly and sends nothing, unless the owner asked for a "nothing new" line.
A failed step sends a short failure note and no report update, and changes neither stories.json nor the period of the next run. When Joshu starts and finds a run that was left running, it marks the run failed and sends the failure note. A live run of 9 lanes with every source took about 2 minutes.
Every model call uses the model of JOSHU_RESEARCH_WRITER_MODEL (default JOSHU_OPENROUTER_DEFAULT_MODEL), no-training routing, reasoning turned off, and one retry. The time limits are 45 seconds for the plan, 90 seconds for the search and the page text, 90 seconds for the fact extraction, 3.5 minutes for the outline and the text, and 45 seconds for the pictures, the HTML page, and the PDF. The social collection has 4 minutes in parallel. A Research report is done or failed within 8 minutes, which leaves time for the wait of the realtime-goal broker (about 60 seconds for a text message request) and the start of the worker, inside the 10 minutes that the owner is promised. A real run took about 2.5 minutes.
Design decisions
2026-10-19: A research pipeline on Exa makes the report
The pipeline follows the steps that published research systems share (Anthropic's multi-agent research system, Stanford STORM, GPT-Researcher, and LangChain open_deep_research): plan sub-questions, search in parallel, read the best pages, extract facts, outline, write, and check. The research runs in parallel, and the text is written in one call. Writing each section in parallel was rejected, because LangChain found that sections written in parallel do not fit together. The Exa Agent API (one asynchronous run with a schema) was rejected for now, because it gives less control over the sources and the pictures and has no time guarantee. Keeping the last30days engine as the web collector was rejected, because it asks Exa for five results for each query, cuts the text to 500 characters, and drops each page without a date. Exa allows 10 searches each second for all boxes of one Exa team, so one box runs at most four at once and waits for Retry-After on HTTP 429.
2026-10-21: Composio Search adds news, papers, and company data
Exa's recent searches return many market-research press releases and miss dated news that Google News has, and a topic about a public company got no market data or filings. Each box already has a Composio key, and the Composio toolkit composio_search needs no sign-in, so the box can call Google News, Google Scholar, Google Finance, and SEC EDGAR through it. In a test on 2026-10-21, the news search returned dated articles from publishers that the Exa runs did not have, and the other web searches of Composio Search (Tavily, DuckDuckGo, and an Exa answer) returned weaker pages than Exa, so the box does not use them. A separate news API or SerpAPI was rejected, because each box would need a new key or a new relay. A Composio Tool Router session was rejected, because the pipeline knows which tool it needs and a session adds calls.
The client posts to the Composio tools API with fetch, the pinned toolkit version 20260903_00, and the Composio user of the box, so that each call has a real time limit and the tests can pass a fake fetch. The toolkit version is pinned because Composio toolkits change their argument schemas without notice. A call has 40 seconds and no retry: most calls take about one second, but a news search for a query that Composio had not seen before took more than 20 seconds in 7 of 15 calls of one test run. One box runs at most four Composio calls at once, and a report makes at most 10. Composio counts these calls as ordinary tool calls of the Composio project and limits them to 1,000 an hour for each Composio organization on the free plan.
News and papers add pages to the Exa pages, and the Exa pages are chosen from the Exa results alone. A first build ranked news and papers together with the Exa pages, and for a topic on a company and its market the news pushed every report, regulator, and consulting source that an Exa-only run had cited out of the pages; the report read worse. So the news adds at most 6 articles (3 read in full), Google Scholar adds only its top paper, and the planner asks Google Scholar only when peer-reviewed studies are the main evidence, because papers on business topics gave filler. News and papers do not get a section of their own, because the writer groups facts by theme. In the saved runs Exa returned no highlight scores, so an Exa page scores only by its rank. The stock prices become a page that code writes, so a sentence about the price passes the number check only when its number is on that page. The first 12,000 characters of a filing are the cover page, so Exa returns the passages that match the research brief instead of the full text.
2026-10-30: The plan says what the topic means, and the owner can name the sections
A report on CLARA Analytics, a claims AI company, said that Hippo Insurance's AI work includes CLARA Analytics. Hippo's own claims voice agent is named Clara, and a news article had mixed the two up. The fact passed the quote check, because the quote is on the page word for word. The check only proves that a page says something, so a source that confuses two things with the same name gets through.
The fix tells each model call what the topic is. First, the plan step writes one sentence on what the topic means and lists up to four things with a similar name. Second, the fact step, the outline, and the writer get that sentence and the list, and the fact step skips facts about the other things, also when the page mixes them up. A recurring report saves the same sentence and list in each lane plan, and the group step gets them. The owner corrects a wrong meaning with notes, which reach the plan step of a Research report and the lane plans and the group step of a recurring report. A rule in code that drops facts with a second company name was rejected, because many true facts name two companies, and the model can tell from the passage which Clara a page means. A check of each fact against a second source was rejected, because it doubles the searches and many true facts have only one source.
Before this change, the owner could not choose the sections of a Research report: the model chose four to six sub-questions and three to six themes. Now the owner can name up to 6 sections. Each section becomes one sub-question, so the searches look for it, and the outline keeps the sections in the owner's order with the owner's words. The page design does not change. A section with no fact is left out, because a section without a cited fact would break the rule that every claim has a source. A recurring report has no sections of its own: each topic is one part of every report update, so the topics are its sections.
2026-10-19: The writer cites facts, and code checks each sentence
The writer gets only the facts, each with its identifier, quote, site, and date, so links, dates, and site names come from the pages and never from the model. Code drops a sentence that cites an unknown fact, and a sentence with a number (two or more digits, a decimal, a percent, or an amount of money) that is not in the quote of a cited fact. This is the idea of Anthropic's citation step and of the FACT score of DeepResearch Bench, done in code: it is free and catches invented numbers, the most harmful error in a report that the owner forwards. A claim without a number can still overstate. The calls turn reasoning off, because the default model is a reasoning model: with reasoning on, even at low effort, it spent about 7,500 tokens and more than two minutes before the answer, and with reasoning off it answered in 28 seconds. Letting the companion write the report in its own turn was rejected, because the result would depend on the main model of the box and on the conversation, and no test could check it.
2026-10-19: Pictures come from the cited pages
For each theme section, the box takes the share image of the first cited page that has one, or else an image of the page with alt text. It keeps a JPEG, PNG, or WebP of at least 300 pixels in width (read from the file header) and at most 1 MB, skips URLs and alt texts that name a logo, an icon, or an avatar, and puts at most six pictures in one report. A picture shows the site name and links to the page. Generated pictures were rejected, because a picture in a research report should show something from a source. Linking to the picture on the web was rejected, because the page must load nothing from the internet.
2026-10-29: Joshu calls the social relays itself
The first version ran the last30days engine for the social box. Recurring reports need social posts on every run of every lane, and the engine made most of its relay calls for comments and transcripts, needed Python 3.12, a pinned copy of another project, and four patches, and had its own scoring. So Joshu calls the ScrapeCreators and Xquik relays with the same request as the patches of the engine: the top Reddit posts of the period, and the YouTube, TikTok, Instagram, and X searches with the date filter of each vendor, with no comments and no transcripts. Code filters the posts by date. Relevance comes from one model call, because the posts with the most engagement are often off topic. A failed source only gets the state "could not be reached". The box shows at most two posts of one source, because the views of a video are far larger numbers than upvotes: a test without that cap filled the box with five TikTok videos. Keeping the engine for social posts only was rejected, because the box should hold no code of that project.
2026-10-29: A recurring report is a folder and a Hermes cron job with a script
Each recurring report is a folder Research/Recurring/<slug>/ with setup.json, stories.json, a README.md that gbrain indexes, updates/, and migration/, and one Hermes cron job with no_agent: true whose script calls the run route, in the way of owner reminders. The Schedules app shows the job, and Hermes runs it in the owner timezone. A Hermes agent cron job that writes the digest was rejected, because such a job cannot send a text message and depends on the gateway, where cron runs have hung and were skipped after a model switch. A scheduler inside Joshu was rejected, because owner reminders already use Hermes cron jobs. A delete removes the job and its script and keeps the folder, so the report updates stay in the owner files.
2026-10-29: Each lane is planned once, and Auto picks the sources once
When a lane is made or its topic changes, one model call plans all new lanes: two web queries, a news query, a social keyword query, a Google Scholar query, and the company, and with Auto two to four sources besides Web and news, with one sentence of reason, only from the sources that the box can use. Two runs then search the same way, so their stories can be compared. Planning each run again was rejected, because new queries make a story look new or quiet only because the search changed.
2026-10-29: Stories and story status are plain files
stories.json keeps each story with its lane, its items, and the time of the last run that found it, and a story that no run found for 60 days is dropped. update.json keeps each story of the run with its story status and new items, so the writer, the Research app, and a later turn of the companion read the comparison instead of making it again. A Research report saves its theme sections as stories, so a repeat request can compare with it. Links match first because they are exact and free; the model only matches what links cannot. A wrong match shows a new story as developing, which still shows it to the owner.
2026-10-29: Joshu writes the digest with the notes of the owner
One model call writes the digest from the notes, the channel format, and the stories, and code checks links and numbers as for a Research report. The companion turns what the owner says about the format into notes, so chat still shapes each digest. The writer gets a ranked subset of the stories, because a starting update can have more than 150 stories, and a live writer ignored a limit of 8 items when it got all of them. Code reads a number of items from the notes ("at most 8 items") and gives the writer only that many; the rest of the notes stay free text for the writer. Letting the companion write each digest in a Hermes turn was rejected for the reasons of the cron job decision.
2026-10-29: Joshu sends report updates without a Hermes turn
Email goes from the companion mailbox to the owner addresses of the box profile (the setup can choose which), as HTML with the signature of the companion, because Nylas reads the body as HTML and plain text loses its line breaks. In the email, the topic of each lane is a heading, and each link shows the name of its site. An email report update also has the update PDF attached. The run renders the PDF with a limit of 90 seconds, and sends the digest without it when the render fails, so a Chromium problem never stops a report update. A text message goes on the owner text channel with its email fallback, and Slack and Telegram go with the send functions of the owner outbox to the latest Slack or Telegram conversation of the owner. When that conversation is gone, the box sends on the owner text channel. The box never takes a recipient from a model answer. An owner outbox item for each report update was rejected, because outbox items belong to realtime goals and carry phone call handling that report updates do not use.
2026-10-29: A repeat request is a report update, and the full update is a PDF
The skill tells the companion to find an earlier Research report first. With one match, a report update runs the steps of a run with one lane from the topic, the queries, and the sources of that report, since its date. The PDF of the full update uses the design of a Research report (the CSS of template.ts, the fonts, and the Chromium render): a cover with the contents, the digest as the summary, a section for each lane with a card for each story, and the pages and posts at the end, with a chart of the new items of each lane over the earlier report updates. The report link covers both folder shapes of a report update with a pattern for each, and the route checks that the path stays in Research/.
2026-10-29: The Watch migration runs once before the cleanup
At the first boot, before the last30days cleanup, Joshu reads the enabled watched topics from the engine database with node:sqlite (read only), finds the Watch jobs and each custom job that reads the Watch results, and makes one recurring report "Watched topics" with one lane for each topic. The schedule and the channel come from the earliest custom job, and one model call turns its prompt into notes on content and format, without recipients or tools. Recipients come only from the addresses of the prompt that are in the box profile. The jobs are copied to migration/, the custom jobs are removed, and a marker file stops a second run.
2026-10-29: The Research app uses the same service as the plugin tools
The app (Vite and React, like the Schedules app) and the plugin tools call the same routes, so the app and the companion always show the same recurring reports. The routes check the ArozOS session of the owner or allow direct localhost. The form offers Slack and Telegram only when the owner has a conversation there. A one-time report from the form runs in the background, and Joshu sends it on the chosen channel, with the PDF attached in email.
2026-10-19: Charts are SVG drawn in TypeScript, and belong to the follow-up report
The chart of items by day and the grid of themes by source describe coverage over time, which is what a follow-up report about the same topic shows. The first report has no charts. A chart library that runs in the browser was rejected, because the PDF render would have to wait for a script, and a mail client that blocks scripts would show no chart in the HTML page.
2026-10-19: Fonts are embedded in each page
Newsreader (variable), Work Sans, and Silkscreen (SIL Open Font License) come from the Fontsource npm packages, are copied to dist/research/fonts/, and are embedded as base64 @font-face. The layout follows the approved editorial mockup, with two later cuts: a theme has no side column of counts and no list of its own sources, because the list of pages read at the end has the sources. The layout has a cover with a side column, a summary with a drop cap, finding cards with the sites that back them under the heading "Key Findings", one page for each theme with a picture and numbered citations, the social box, a closing card of the companion, and the list of pages read. Google Fonts was rejected, because the render would need the network, a failed request silently gives a fallback font, and each opening of a forwarded page would ask Google. Installing the fonts in the image was rejected, because a forwarded HTML page would not have them.
2026-10-19: The PDF comes from a one-shot headless Chromium run
The image already has Chromium. The run uses a new temporary profile and a proxy that points at a closed local port, so it cannot reach the network. The printed page has normal margins and a white background, so it prints well on paper; the paper color is only for the HTML page on a screen. Pictures are scaled to fit, never cropped, because a cropped chart loses its numbers. Printing in the shared browser was rejected, because the owner sees that browser in jWeb and its traffic goes through the residential proxy. A Python PDF library was rejected as a new dependency with weaker CSS support.
2026-10-19: Each Research report is one folder
The folder name is the report identifier, and earlier reports are found by a scan of the folders. A database table was rejected, because the owner can delete a folder in the file manager and a table would then point at a missing folder. The data file (report.json, schemaVersion: 2) holds the plan, the pages with their dates, the facts with their quotes, the social posts, the text, the dropped sentences, and the credits of the pictures, so a follow-up report can compare two runs of one topic.
2026-10-19: Synchronous tools in a Hermes plugin
An async tool with a reply into the Hermes session was rejected, because that reply reaches only jChat. A tool on the connectors MCP was rejected, because MCP tool calls time out after 300 seconds. The plugin is enabled in the global toolsets (which workers use) and in platform_toolsets.api_server (jChat). The retired plugin joshu-last30days is dropped from both at each gateway sync.
2026-10-19: The owner outbox uploads the PDF
The report link in the text is the handle of the file, so the kanban task, the realtime-goal broker, and the outbox item format did not change. A new attachment field on the outbox item was rejected, because the summary of a worker is free text, and three parts would need the new field for one feature. The MEDIA: tags of the Hermes gateway were rejected, because the outbox, not the gateway, sends realtime-goal results, and the gateway refuses files under /var/lib, where the owner files are. When Slack or Telegram refuses the file, the sender sends the text alone, which still has the link.
2026-10-19: The report link is a signed public route
/joshu/api/research/r/<report identifier>/<signature>/report.pdf serves the PDF inline without a sign-in. The signature is HMAC-SHA256 over the identifier with a per-box secret, cut to 128 bits in base64url, so the link has only ASCII characters and no ; and survives the SMS text handling. The link has no expiry, so a forwarded link keeps working; deleting the folder stops it, and rotating the secret stops all links. An ArozOS share with the permission "anyone" was rejected, because ArozOS creates a share only with the session of a signed-in owner. An expiring token was rejected, because a forwarded link would stop working.
2026-10-19: Boxes remove the last30days app at boot
Deleting the app from the image does not delete its copies on a box. At boot, Joshu removes the two watch Hermes cron jobs by name, their scripts, the copied skills and their registry entries, the ArozOS subservice folder, and the desktop shortcuts. It keeps the old results in research/last30days/ and the engine state.
Limits and failure modes
- A box without an Exa key cannot make a Research report or run a recurring report: both fail at step "search". A box without the social relays makes a report without the social box, and its runs name the social sources as "could not be reached". A box without a Composio key makes a report from Exa only, and its list of sources names no extra source.
- The model can still overstate in a sentence without a number. The check covers citations and numbers, not the meaning of the prose.
- The number check is strict. A sentence that rounds a number or adds a date that is not in the cited quote is dropped, so a theme can be shorter than the model wrote it. The dropped sentences are in the data file.
- Exa costs about $0.10 to $0.30 for each report (about 12 searches and 12 page texts, and one more call for the filings of a company).
- The Google News search has no country of the owner, so it can return news of another country. The fact extraction skips pages that do not answer the sub-question.
- The company name check accepts one shared word, so it catches a clearly wrong ticker, not a company with a similar name. A company that is listed only outside the United States has a price page and no filings.
- Google Scholar links often go to journal pages behind a paywall, so the box reads only the abstract.
- Some sites block the download of their pictures. The section then has no picture.
- Anybody who has a report link can open the PDF. The route sends
X-Robots-Tag: noindex. - The owner files have both
Research/(new reports) andresearch/last30days/(old results). - When a step fails, the companion tells the owner, and the owner can ask again. The box does not retry by itself.
- Two Research reports at once run two Chromium runs, which a box with 8 GB of memory handles.
- A run of 9 lanes makes about 27 Exa searches, 9 Google News searches, about 36 relay calls, and 12 model calls. No limit on the number of recurring reports or lanes is set yet.
- A story can look developing because of one minor new post. The notes limit the number of items, and the writer ranks the stories by new items and sources.
- A digest by text message can be long, and Twilio splits it into several messages.
- Instagram reel search has no date filter, so code drops the old reels after the search.
Code map
| Part | Path |
|---|---|
| Research pipeline | src/research/pipeline/ (llm.ts, exa.ts, composioSearch.ts, plan.ts, search.ts, company.ts, extract.ts, write.ts, pictures.ts) |
| Composio Search pin | src/connectors/composio/composioSearchConfig.ts |
| Social sources and the social box | src/research/social/ (relay.ts, sources.ts, socialBox.ts, health.ts) |
| Recurring reports | src/research/recurring/ (types.ts, store.ts, schedule.ts, plan.ts, service.ts, collect.ts, stories.ts, digest.ts, delivery.ts, run.ts, runtime.ts, repeat.ts, updatePdf.ts, watchMigration.ts, routes.ts) |
| Research app | apps/research/, arozos/subservice/research/ |
| Charts, template, fonts | src/research/charts.ts, src/research/template.ts, src/research/fonts.ts |
| PDF render | src/research/pdf.ts |
| Whole run, store | src/research/make.ts, src/research/store.ts |
| Report links, routes | src/research/reportLinks.ts, src/research/routes.ts |
| Boot cleanup and Watch migration | src/research/retireLast30days.ts, src/research/recurring/watchMigration.ts |
| Outbox upload | src/realtimeGoals/delivery.ts, src/realtimeGoals/ownerOutboxSenders.ts |
| Hermes plugin and config | .hermes/plugins/joshu-research/, syncResearchPlugin in src/hermesApi.ts |
| Skill | integrations/hermes/skills/research/research-report/SKILL.md |
| Tests | npm run test:research-social, test:research-recurring, test:research-recurring-run, test:research-watch-migration, test:research-pipeline, test:research-report, test:research-pdf, test:research-hermes, test:research-outbox, test:research-retire; fixtures in test/fixtures/research/ (Composio Search answers and saved ranking runs in composio/, relay answers in social/, Hermes cron jobs of a Watch box in watch/) and scripts/lib/research-test-fakes.mjs; the Composio Search pin in npm run test:composio-toolkit-pins |
This page comes from docs/technical-reference/research-reports.md in the open-source Joshu repo.