<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Neel B</title>
    <description>The latest articles on DEV Community by Neel B (@neel_b_05a240ea1a5ae2964d).</description>
    <link>https://dev.to/neel_b_05a240ea1a5ae2964d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4201004%2Fe8649802-7c39-4acb-834f-dcfaf56814b6.jpg</url>
      <title>DEV Community: Neel B</title>
      <link>https://dev.to/neel_b_05a240ea1a5ae2964d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/neel_b_05a240ea1a5ae2964d"/>
    <language>en</language>
    <item>
      <title>I Built a Finance Assistant Where Six AI Agents Check Each Other's Work</title>
      <dc:creator>Neel B</dc:creator>
      <pubDate>Thu, 22 Oct 2026 19:14:05 +0000</pubDate>
      <link>https://dev.to/neel_b_05a240ea1a5ae2964d/i-built-a-finance-assistant-where-six-ai-agents-check-each-others-work-1ele</link>
      <guid>https://dev.to/neel_b_05a240ea1a5ae2964d/i-built-a-finance-assistant-where-six-ai-agents-check-each-others-work-1ele</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fso43jz4h6hxdv3lzd1nb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fso43jz4h6hxdv3lzd1nb.png" alt="The assistant explaining a portfolio's value and top holdings, next to Indian market movers" width="799" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I typed "can you check my portfolio" into my own app. About 13 seconds later it told me the holdings were worth ₹15,463.77, up ₹2,235.22 (16.9%) overall and down ₹269.55 for the day, then listed the three biggest positions with their weights. Every number came from the actual holdings data, and the answer said where it came from.&lt;/p&gt;

&lt;p&gt;That one question went through four steps. A planner decided it was a portfolio question, a Portfolio agent read the holdings, a reviewer checked the findings, and a final step wrote the answer. Bigger questions fan out to several agents at once.&lt;/p&gt;

&lt;p&gt;The app is called &lt;strong&gt;Finance Agents&lt;/strong&gt;. It's an educational assistant for Indian and world markets, built with LangGraph.js. A supervisor plans each question, sends it to six specialist agents that run in parallel, checks what they found, and writes one answer. The agents can search your own PDFs (with RAG that runs entirely on your machine), read live market data, and see your Zerodha Kite holdings. It's React and TypeScript in one folder, and it's open source.&lt;/p&gt;

&lt;p&gt;One thing up front: it's for learning about money, not for telling you what to buy. Nothing it says is financial advice.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/neel-turbo/multi-agent-langraph-rag-finance-app" rel="noopener noreferrer"&gt;github.com/neel-turbo/multi-agent-langraph-rag-finance-app&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;There are two screens. The chat screen has your past conversations on the left, the chat in the middle and market movers on the right, with a live ticker running across the top. On a tablet it drops to two columns, and on a phone the conversation list folds into a toggle.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fihp60mdz8i2bhpbm3yn5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fihp60mdz8i2bhpbm3yn5.png" alt="Chat: a finance answer with sources, saved conversations and world market gainers" width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The market panel switches between India (Nifty, Sensex) and world indices, and shows the top gainers and losers with little sparklines. It uses Yahoo by default, so you don't need an API key for any of it.&lt;/p&gt;

&lt;p&gt;The second screen is the portfolio. Holdings come from Zerodha Kite, and you get summary tiles (current value, invested, total P&amp;amp;L, today's change), allocation by value, P&amp;amp;L per holding, a price-history chart with your average buy price, and the full holdings table. Click a bar or a row and the chart switches to that stock.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvowkyd8ec0x7usj5pczr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvowkyd8ec0x7usj5pczr.png" alt="Portfolio dashboard: summary tiles, allocation and P&amp;amp;L per holding for the Kite demo account" width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By default it reads Zerodha's public demo account (17 holdings), so you can try everything without connecting your own. Every chat is also saved as a LangGraph thread, so you can close the tab, come back later and pick up an old conversation where you left it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the agents work together
&lt;/h2&gt;

&lt;p&gt;The whole thing is one LangGraph graph called &lt;code&gt;supervisor&lt;/code&gt;. Here's what happens to every question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plan ──Send──▶ agents (parallel) ──▶ review ──┬──Send──▶ agents ──▶ review ...
  │                                           └──────▶ synthesize ──▶ END
  └── small talk ──▶ END
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here's the same graph in LangSmith Studio, with all six specialists between plan and review:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwdidjzmn21xn7oo3bvaa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwdidjzmn21xn7oo3bvaa.png" alt="LangSmith Studio: plan, six specialist agents, review and synthesize" width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Plan.&lt;/strong&gt; If you just type "hi" or "thanks", a regex answers instantly and no model is called at all. Anything else goes to the planner, which splits the question into tasks and gives each one to the specialist whose tools can answer it. A task can depend on another one. A question like "can I reach my goal with what I hold now?" needs the Portfolio agent to finish before Goal Planning can start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Agents.&lt;/strong&gt; Each specialist is a ReAct agent: it thinks, calls a tool, looks at the result and repeats until it has an answer with sources. Agents that don't depend on each other run in parallel. LangGraph's &lt;code&gt;Send&lt;/code&gt; makes that fan-out short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fanOut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SupervisorStateType&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;formatOutputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentOutputs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;userProfile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userProfile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;round&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;round&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;satisfies&lt;/span&gt; &lt;span class="nx"&gt;AgentTaskInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each agent gets a small private slice (its task, what the other agents found so far, the user's profile and the round number) instead of the whole graph state. That keeps every agent's prompt short and focused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Review.&lt;/strong&gt; This is the part I'm happiest with. It starts with plain code: any task whose dependencies are now done gets released, with no model call. If one agent answered a simple question in the first round, review gets out of the way. Otherwise a reviewer model reads all the findings together and looks for contradictions, gaps and numbers without a source. It can send follow-up tasks back to the agents, for up to 3 rounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Synthesize.&lt;/strong&gt; Everything gets merged into one answer, along with the reviewer's notes, and streamed to the browser. Only this step streams. The planning and agent calls are tagged so their tokens stay off the screen, and the chat shows progress lines like "Planned: Finance Q&amp;amp;A" instead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7e90sx57zzpoejkyge7d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7e90sx57zzpoejkyge7d.png" alt="The chat showing " width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These are the six specialists and the tools each one gets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;What it handles&lt;/th&gt;
&lt;th&gt;Tools&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Finance Q&amp;amp;A&lt;/td&gt;
&lt;td&gt;Concepts and definitions, explained for a learner&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rag_search&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Portfolio&lt;/td&gt;
&lt;td&gt;Your holdings: allocation, concentration, risk, P&amp;amp;L&lt;/td&gt;
&lt;td&gt;Kite holdings (in its prompt), &lt;code&gt;stock_quote&lt;/code&gt;, &lt;code&gt;rag_search&lt;/code&gt;, &lt;code&gt;sip_future_value&lt;/code&gt;, &lt;code&gt;cagr&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Market&lt;/td&gt;
&lt;td&gt;Indices, gainers and losers, a stock's price, why the market moved&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;market_movers&lt;/code&gt;, &lt;code&gt;stock_quote&lt;/code&gt;, &lt;code&gt;market_search&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Goal Planning&lt;/td&gt;
&lt;td&gt;Target amount, time horizon, monthly saving needed&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sip_future_value&lt;/code&gt;, &lt;code&gt;cagr&lt;/code&gt;, &lt;code&gt;rag_search&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;News&lt;/td&gt;
&lt;td&gt;Recent financial news and why it matters&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;news_search&lt;/code&gt;, &lt;code&gt;market_search&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tax&lt;/td&gt;
&lt;td&gt;Tax rules, slabs, deductions, account types&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rag_search&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Portfolio agent is the odd one out. It doesn't have to call a tool to see your holdings: they're put straight into its task as compact JSON, so it starts with the data in front of it. The planner sends any "my portfolio", "my stocks" or "my P&amp;amp;L" question its way.&lt;/p&gt;

&lt;h2&gt;
  
  
  RAG that never leaves your machine
&lt;/h2&gt;

&lt;p&gt;The Finance Q&amp;amp;A, Portfolio, Goal Planning and Tax agents can search your own PDFs: a tax guide, a fund factsheet, a textbook chapter. I wanted that part to be fully local, so there's no parsing API, no embedding API, and no document goes anywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PDF ──LiteParse──▶ Markdown, page by page ──chunker──▶ chunks + metadata
    ──bge-small (local)──▶ vectors ──▶ ChromaDB "finance_docs"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You add documents with one command, tagging them so agents can narrow their search later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run ingest &lt;span class="nt"&gt;--&lt;/span&gt; pdfs/tax_guide_2025.pdf &lt;span class="nt"&gt;--domain&lt;/span&gt; tax &lt;span class="nt"&gt;--jurisdiction&lt;/span&gt; IN &lt;span class="nt"&gt;--tax-year&lt;/span&gt; 2025
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Parsing.&lt;/strong&gt; LiteParse is LlamaIndex's local PDF parser. It's written in Rust, runs inside Node and turns the PDF layout into Markdown, so headings stay headings and tables stay tables. Scanned PDFs can go through OCR with &lt;code&gt;--ocr&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chunking.&lt;/strong&gt; Every heading starts a new chunk and gets copied to the top of it, so a chunk still makes sense when an agent reads it on its own. Tables always get their own chunk and are never split, so a tax slab table stays whole. Text fills a chunk up to 1,800 characters (about 450 tokens, inside bge-small's 512-token limit). Anything under 120 characters gets dropped, which clears out stray page numbers and footers. Chunks never cross a page boundary, so every chunk has exactly one page number to cite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embedding.&lt;/strong&gt; &lt;code&gt;bge-small-en-v1.5&lt;/code&gt; runs in Node through Transformers.js. No Python. The model is about 130 MB, downloads once, and works offline after that. Each chunk becomes a 384-number vector. Search questions get the query prefix bge recommends; stored passages don't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Searching.&lt;/strong&gt; When an agent calls &lt;code&gt;rag_search&lt;/code&gt;, it can filter by domain, country and tax year, or ask for tables only (the Tax agent does this for rates and slabs). The tool takes the 6 closest chunks and keeps only the ones with a similarity of at least 0.68. Each passage comes back with its citation, like &lt;code&gt;[tax_guide_2025.pdf, page 42 — Capital gains on equity]&lt;/code&gt;, and agents are told to cite every fact that way.&lt;/p&gt;

&lt;p&gt;If nothing clears the threshold, or ChromaDB isn't running, the tool searches the web with Tavily instead and says so in its answer. The agent keeps going instead of failing, and you can see from the citations whether an answer came from your documents or the web.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bit me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The parser threw away page numbers.&lt;/strong&gt; LiteParse gives you a whole document as one Markdown string, with nothing marking where a page ends. That's useless when every answer has to cite &lt;code&gt;[document, page]&lt;/code&gt;. So the ingest script counts the pages first, then parses them one at a time and drops a separator line before each page's Markdown. The chunker reads those separators to know which page it's on. It's an extra pass per PDF, but now every chunk carries exactly one page number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Picking the similarity cut-off.&lt;/strong&gt; Too low and the agents quote irrelevant passages with total confidence. Too high and they never use your documents. I measured it on my own index: on-topic tax questions scored between 0.71 and 0.88 with bge-small, off-topic ones between 0.52 and 0.63. The default of 0.68 sits in that gap, and you can change it with &lt;code&gt;RAG_MIN_SIMILARITY&lt;/code&gt; if your documents behave differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small models choked on JSON objects.&lt;/strong&gt; The Portfolio agent gets your holdings in its prompt. Sending one object per holding repeated every key 17 times, and small local models with a 4K context started rambling. Switching to one list of column names plus plain rows fixed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Columns + rows instead of one object per holding: under half the tokens, and small local&lt;/span&gt;
&lt;span class="c1"&gt;// models (4K context) read it reliably where the object form made them ramble.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;holdings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;HOLDING_COLUMNS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HOLDING_COLUMNS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One failing agent shouldn't sink the whole answer.&lt;/strong&gt; Every agent retries on a fallback provider (local Ollama by default) if the main one hits a quota or an outage. If the fallback fails too, that agent returns a short &lt;code&gt;[portfolio failed: …]&lt;/code&gt; note instead of throwing, and the other agents carry on. The reviewer and the final step both see that note, so the answer can say what's missing instead of the whole turn erroring out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every provider wants different settings.&lt;/strong&gt; Current Claude models and OpenAI's GPT-5 family reject a custom &lt;code&gt;temperature&lt;/code&gt;, so the app doesn't send one to them. Gemini and Ollama get &lt;code&gt;temperature: 0&lt;/code&gt; for consistent answers. Claude's structured output also had to use its native JSON-schema mode, because a forced tool call isn't allowed while thinking is on. All of that lives in one file, &lt;code&gt;models.ts&lt;/code&gt;, so the agents never have to care which model they're running on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API keys can't leak into the browser by accident.&lt;/strong&gt; The browser's TypeScript project excludes &lt;code&gt;src/server&lt;/code&gt; entirely. If a React component ever imports server code, the build fails with a compile error instead of quietly shipping a key to the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;You'll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node.js 20 or newer&lt;/li&gt;
&lt;li&gt;An LLM API key: Gemini by default, or Claude or OpenAI&lt;/li&gt;
&lt;li&gt;A Tavily API key for web, news and market search&lt;/li&gt;
&lt;li&gt;Ollama, if you want a local fallback model (optional)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Market data from Yahoo and the Kite demo portfolio need no keys at all. ChromaDB comes with the project's npm packages, so you don't need Python or Docker for it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Install&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt;

&lt;span class="c"&gt;# 2. Configure: set TAVILY_API_KEY and your LLM key (GOOGLE_API_KEY by default)&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env

&lt;span class="c"&gt;# 3. Start ChromaDB in its own terminal and leave it running&lt;/span&gt;
npx chroma run &lt;span class="nt"&gt;--path&lt;/span&gt; ./local-db-data

&lt;span class="c"&gt;# 4. Add your PDFs (once per document)&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; pdfs
npm run ingest &lt;span class="nt"&gt;--&lt;/span&gt; pdfs &lt;span class="nt"&gt;--domain&lt;/span&gt; general

&lt;span class="c"&gt;# 5. Optional: a local fallback model (it must support tool calling)&lt;/span&gt;
ollama pull qwen3.5:4b

&lt;span class="c"&gt;# 6. Start the web app, the agents and the market API&lt;/span&gt;
npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open &lt;code&gt;http://localhost:5173&lt;/code&gt;. One gotcha: &lt;code&gt;npm run dev&lt;/code&gt; does not start ChromaDB, so keep step 3 running in a second terminal. If Chroma is down the app still works, but every answer will cite the web instead of your PDFs.&lt;/p&gt;

&lt;p&gt;You can switch every agent to a different provider from &lt;code&gt;.env&lt;/code&gt;, without touching code. You can also mix them per role, for example a strong model for planning and a cheaper one for the many agent calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PLANNER_PROVIDER=anthropic   # planning, review and the final answer
PLANNER_MODEL=claude-opus-5
AGENT_PROVIDER=openai        # the specialists' tool loops
AGENT_MODEL=gpt-5-mini
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's worth doing, because one question makes several model calls: a plan, one or more agents, a review and the answer. The server prints which model each role uses when it starts, and a missing key stops it straight away with a clear message instead of failing on your first question.&lt;/p&gt;

&lt;p&gt;The LangGraph dev server also connects to LangSmith Studio, so you can watch every run step by step. In the trace for my portfolio question, review took 0.01 seconds: one agent had answered a simple question, so the fast path skipped the reviewer model completely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fudgldxrlmbrbkzdfxfec.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fudgldxrlmbrbkzdfxfec.png" alt="LangSmith trace of a portfolio question: plan, portfolio agent, review and synthesize, with timings and token counts" width="800" height="472"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still rough
&lt;/h2&gt;

&lt;p&gt;I'd rather you hear it from me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;There's no login yet. The user id is hardcoded, so every conversation belongs to one user.&lt;/li&gt;
&lt;li&gt;Top gainers and losers are ranked within the app's own watchlists, not the whole market.&lt;/li&gt;
&lt;li&gt;Yahoo market data comes from an unofficial API. If it breaks, you can switch to Twelve Data or Alpha Vantage with a key.&lt;/li&gt;
&lt;li&gt;The Kite demo account's prices are a fixed snapshot from 2023, while the price chart uses today's Yahoo prices, so the two won't match. A real Kite account fixes that, but its access token expires every day.&lt;/li&gt;
&lt;li&gt;The LangGraph dev server is for local use. For production you'd deploy the graph with a persistent checkpointer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  That's it
&lt;/h2&gt;

&lt;p&gt;What I like about this setup is that every answer is checkable. You can see which agents ran, what each one found, and which page of which document a number came from. When the documents don't have the answer, it tells you it went to the web.&lt;/p&gt;

&lt;p&gt;The code is on GitHub: &lt;a href="https://github.com/neel-turbo/multi-agent-langraph-rag-finance-app" rel="noopener noreferrer"&gt;neel-turbo/multi-agent-langraph-rag-finance-app&lt;/a&gt;. If you try it with your own PDFs, I'd like to hear which questions it handles well and where it stumbles. Issues and PRs are welcome.&lt;/p&gt;

&lt;p&gt;And if this was useful, a star on the repo helps other people find it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>langchain</category>
      <category>typescript</category>
      <category>rag</category>
    </item>
    <item>
      <title>I Built an App That Turns Phone Photos into 3D Models, and Nothing Leaves My Wi-Fi</title>
      <dc:creator>Neel B</dc:creator>
      <pubDate>Thu, 22 Oct 2026 18:13:29 +0000</pubDate>
      <link>https://dev.to/neel_b_05a240ea1a5ae2964d/i-built-an-app-that-turns-phone-photos-into-3d-models-and-nothing-leaves-my-wi-fi-mo6</link>
      <guid>https://dev.to/neel_b_05a240ea1a5ae2964d/i-built-an-app-that-turns-phone-photos-into-3d-models-and-nothing-leaves-my-wi-fi-mo6</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvtmrf100cmoalsag6114.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvtmrf100cmoalsag6114.jpg" alt="A 3D scan of my mouse, placed on the rug in AR" width="300" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That mouse on the rug isn't really there. The real one is sitting on my desk. What you're looking at is a 3D copy of it, dropped into my room in AR, and it was built from photos I took on my phone.&lt;/p&gt;

&lt;p&gt;The part I care about most is where it was built. Not on someone's GPU farm. On my MacBook, over my home Wi-Fi. The photos never left the house, and there was no sign-up, no upload and no credits to buy.&lt;/p&gt;

&lt;p&gt;The app is called &lt;strong&gt;ThreeD&lt;/strong&gt;. It's a React Native app for iPhone and Android, plus a small Node server that runs on your Mac and drives three engines: Apple's Object Capture, Tencent's Hunyuan3D (through ComfyUI) and a small vision model in Ollama. It's open source. In this post I'll show you what it does, how it's put together, and the things that bit me while building it.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/neel-turbo/app-image-to-3d-local-ai-rn" rel="noopener noreferrer"&gt;github.com/neel-turbo/app-image-to-3d-local-ai-rn&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to make a model
&lt;/h2&gt;

&lt;p&gt;Open the app and the first thing it asks is how you want to capture. There are three answers, and each one trades accuracy for effort.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qze4k58xojvazoh7qe4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qze4k58xojvazoh7qe4.jpg" alt="Home screen: pick a capture mode, Mac connected" width="300" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Photo set&lt;/strong&gt; is the one I recommend. You walk around the object three times (eye level, from above, from low down) and take 12 photos on each ring, roughly 30° apart. That's 36 photos, although the Build button unlocks once you have 20 good ones. The Mac runs Apple's Object Capture on them and you get the object's real shape with its real colours. Small things like a logo or a button survive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4 photos (AI)&lt;/strong&gt; is the middle ground. Front, back and both sides go to Hunyuan3D-2's multi-view model, so the AI isn't guessing what the back looks like. It saw it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1 photo (AI)&lt;/strong&gt; is the quick try. One clear photo, and Hunyuan3D-2 mini imagines the rest. Front and sides look good. The back is a guess, and sometimes a funny one: details from the front, like a face, get mirrored onto the back of the head.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0xkmdmdn10m7vwh1t8ps.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0xkmdmdn10m7vwh1t8ps.jpg" alt="4 photos mode: front, sides and back" width="300" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you just want to play, the single photo is fine. If you want something you'll keep, take the extra two minutes and walk around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most of the work is stopping bad photos
&lt;/h2&gt;

&lt;p&gt;The hard part of photogrammetry isn't the maths. It's getting a human (me included) to take decent photos. A blurry shot, two photos from the same spot, the object half out of frame: any of those and Object Capture either hands you a broken mesh or gives up with "not enough features". So most of the phone-side code exists to catch bad photos before they ever reach the Mac.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fncwt1mkpnojtnwk6c3ei.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fncwt1mkpnojtnwk6c3ei.jpg" alt="Photo set: ring 1, eye level, shot 1 of 12" width="300" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;First, the camera tells you where to stand. A guide grid sits over the preview with a top-down dial of the 12 spots around the object, a 3 × 12 grid of every photo you still need (Above, Eye level, Low), and one plain line like "Step about 30° to your right". Once you get the hang of it you can turn it off.&lt;/p&gt;

&lt;p&gt;Auto-capture is my favourite trick in the app. When you walk around an object, your phone turns by the same angle you walked. So the app reads the motion sensors (CoreMotion on iPhone, the rotation vector on Android) and waits until you've moved about 30° to a new spot, at the right height for the ring. Hold still and it takes the shot. No two photos from the same place, and no tapping.&lt;/p&gt;

&lt;p&gt;Every photo is then checked in C++: sharpness (variance of the Laplacian), exposure, contrast, and overlap with the previous shot using a 64-bit dHash. It's one pure C++ Turbo Module, so the same code runs on iOS and Android, and I can unit-test it on my laptop. JavaScript only decides what to do with the numbers ("too blurry, try again"). A bad shot gets rejected on the spot, with a tip.&lt;/p&gt;

&lt;p&gt;The phone also cuts the object out of the background, with Apple Vision on iPhone and ML Kit Subject Segmentation on Android. For the AI modes that cut-out is centred on white with a 15% border, because that's the kind of input Hunyuan3D was trained on.&lt;/p&gt;

&lt;p&gt;Finally there's an "Ask local AI" button. A 4B vision model (&lt;code&gt;qwen3.5:4b&lt;/code&gt;, running in Ollama on your Mac) names the object, rates the photo Good, OK or Poor, and gives you tips. I didn't want a small model overruling a Laplacian filter about whether a photo is sharp, so the phone sends its own measurements along with the image and the gateway drops any tip that contradicts them. It runs at temperature 0 with a fixed seed too, so the same photo always gets the same advice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then you get to play with it
&lt;/h2&gt;

&lt;p&gt;When a build finishes, it shows up in the Gallery tab. Every model your Mac has built is there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyww9ck184b3s25ss1dpz.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyww9ck184b3s25ss1dpz.jpg" alt="Gallery: every model built on your Mac" width="300" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Tap one and you get a proper 3D viewer. Drag in any direction (up and down too, so you can check the underside), pinch to zoom, or jump straight to Front, Back, Top and the rest. The viewer is Google's &lt;code&gt;&amp;lt;model-viewer&amp;gt;&lt;/code&gt;, but it's served by the Mac rather than a CDN, so it works with no internet at all. It also uses soft studio lighting, so the colours look like the real object.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk6k1tm0hrqriwtgc5njf.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk6k1tm0hrqriwtgc5njf.jpg" alt="3D viewer: drag to orbit, jump to any view" width="300" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;View in AR&lt;/strong&gt; is the fun bit. On iPhone it opens AR Quick Look with a &lt;code&gt;.usdz&lt;/code&gt; file. On Android it hands a &lt;code&gt;.glb&lt;/code&gt; to Google's Scene Viewer. Either way you can put the model on your floor and walk around it like it's actually there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff1z2l7s9qo8p71v32jvl.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff1z2l7s9qo8p71v32jvl.jpg" alt="AR: walk around it like a real object" width="300" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sharing goes through the normal share sheet. You can send the 3D file, the AR file (iPhone), a picture of the current view, or a view-only link. The link works in any browser on the same Wi-Fi for 24 hours, needs no app and no pairing, and only ever exposes a random link id, never your device token.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmsw3kgmg7sr9uovdxlzq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmsw3kgmg7sr9uovdxlzq.jpg" alt="Share: 3D file, AR file, picture or view-only link" width="300" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's put together
&lt;/h2&gt;

&lt;p&gt;There are two halves. The phone does the capturing and all the per-photo maths. The Mac does the heavy 3D work. They talk over plain HTTP and a WebSocket on your Wi-Fi, and the phone finds the Mac with Bonjour (mDNS).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 📱 Phone app                          💻 Your Mac (same Wi-Fi)
 ─────────────                         ────────────────────────
 1. Take or choose ── upload ───────▶  2. ThreeD gateway (Fastify)
    photos (checked                        ├─ Apple Object Capture  (photo set → 3D)
    and cut out                            ├─ ComfyUI + Hunyuan3D   (1 or 4 photos → 3D)
    on the phone)                          └─ Ollama                (capture tips)
                                                    │
 4. View, AR, share ◀──── 3D model ─────  3. Builds and cleans the model
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The phone app is a tiny super app
&lt;/h3&gt;

&lt;p&gt;If you write React Native, this is probably the interesting bit. The app is a &lt;strong&gt;shell&lt;/strong&gt; plus two &lt;strong&gt;mini apps&lt;/strong&gt; (Gallery and Profile) that are downloaded over the air. That means I can ship a Gallery fix without waiting on an App Store review.&lt;/p&gt;

&lt;p&gt;Metro can't load separately built bundles at runtime, so the project uses &lt;strong&gt;Re.Pack (Rspack) with Module Federation 2&lt;/strong&gt; instead. The shell doesn't hard-code any remotes. On launch it reads a manifest from the Mac and registers each mini app with &lt;code&gt;registerRemotes()&lt;/code&gt;, so the version can change without a new binary.&lt;/p&gt;

&lt;p&gt;A few rules made this hold together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every native module lives in the shell.&lt;/strong&gt; The stores don't let you download native code, so mini apps ship JavaScript only. VisionCamera, Firebase, the WebView, my C++ module: all of it is in the shell and shared as Module Federation singletons. There's a Jest test that fails if a native dependency isn't shared or its version drifts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One Redux store for everything.&lt;/strong&gt; The Gallery mini app injects its own slice when its bundle runs, so its state is code-split along with its code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every bundle is signed.&lt;/strong&gt; Re.Pack signs each chunk with RSA and the shell refuses anything unsigned or modified. Rollouts are staged (say, 20% of phones first), and if a version crashes while rendering, that phone blacklists it and falls back to the previous one. The shell itself never goes down.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftsush6rxewkqb5rxtr98.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftsush6rxewkqb5rxtr98.jpg" alt="Profile: mini app updates, analytics consent and versions" width="300" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Mac runs a small gateway
&lt;/h3&gt;

&lt;p&gt;The phone never talks to Ollama or ComfyUI directly. Neither has any authentication, and on an 8 GB Mac they can't both be loaded at once anyway. So there's a Fastify server in the middle that does four jobs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pairs phones with a 6-digit code (the device token is only stored as a SHA-256 hash).&lt;/li&gt;
&lt;li&gt;Takes the uploads, re-hashes them and throws away duplicates or anything that isn't really a JPEG or PNG.&lt;/li&gt;
&lt;li&gt;Runs &lt;strong&gt;one heavy job at a time&lt;/strong&gt; and evicts the other models from memory first.&lt;/li&gt;
&lt;li&gt;Cleans up the mesh afterwards and colours it from your photos.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbr8kfzy2bhxx8jyhqwag.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbr8kfzy2bhxx8jyhqwag.jpg" alt="The gateway on the Mac: pairing code, Wi-Fi address, Ollama ready" width="800" height="144"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Don't underestimate the clean-up step. Raw AI meshes come out with floating bits, small holes and far too many triangles. The gateway welds the mesh, removes the floaters, fills holes, simplifies it with meshoptimizer to around 80,000 triangles and smooths the normals. Then it projects colour from your photo (or all four photos) with a depth test, so colour never "shoots through" to the wrong side.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuhvda2uvexqzywsytzw4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuhvda2uvexqzywsytzw4.jpg" alt="ComfyUI running on the Mac's GPU (MPS) for the AI modes" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bit me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;8 GB of memory is a hard wall.&lt;/strong&gt; My test machine is an M1 MacBook Air with 8 GB. Object Capture, a Hunyuan3D diffusion model and a vision LLM simply don't fit in unified memory together. So before every 3D job the gateway unloads the Ollama model, and after the job it asks ComfyUI to free its memory. Unloading Ollama turned out to be one tiny request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** keep_alive: 0 evicts the model from unified memory immediately. */&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;unload&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/generate`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;keep_alive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While a build owns the memory, "Ask local AI" gets an HTTP 409 ("Your Mac is building a model") instead of a crash. Not glamorous, but it's the difference between a slow app and a Mac that locks up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shiny, thin and dark things don't scan.&lt;/strong&gt; Photogrammetry needs texture to match points between photos. A laptop or a phone screen comes out broken no matter how careful you are. Matte, solid objects on a textured table work best. When Object Capture fails with "not enough features", the gateway retries once at high sensitivity before giving up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android AR went black, and it wasn't my bug.&lt;/strong&gt; At one point, tapping View in AR on Android just showed a black screen and bounced back to the app. Google's Scene Viewer (it lives inside the Google app) was crashing, and it crashed with Google's own sample models too. Updating or clearing the cache of the Google app fixes it. Separately, Scene Viewer was dropping big textures, so the gateway now makes a lighter Android AR copy with textures capped at 2048 px, and colours always show.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI models came out grey.&lt;/strong&gt; Hunyuan3D-2 mini gives you shape only, no colour. The gateway now paints the mesh from your photo. For models built before that fix there's a repair script, &lt;code&gt;pnpm --filter @threed/gateway fix-ar-textures&lt;/code&gt;, which colours the old grey ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model files lie about what they are.&lt;/strong&gt; People rename checkpoints, and ComfyUI fails with a cryptic &lt;code&gt;Value not in list: ckpt_name&lt;/code&gt;. Now the gateway opens the &lt;code&gt;.safetensors&lt;/code&gt; header and works out what's actually inside: Hunyuan3D mini, full v2, 2.1, a multi-view model, or a plain 2D model like SD 1.5 that can't make meshes at all. Then it picks the right settings by itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OTA is not a loophole.&lt;/strong&gt; Apple and Google allow interpreted-code updates that don't change what the app does, so I use OTA for fixes and tweaks. When sharing needed new native modules (&lt;code&gt;react-native-share&lt;/code&gt; and a file-system module), that meant a new shell release, 1.1. The Gallery 1.1 update is still safe to push to 1.0 shells: they just fall back to React Native's built-in Share for links.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to expect on an 8 GB M1
&lt;/h2&gt;

&lt;p&gt;These are measured on the base M1 MacBook Air, so pretty much any Apple Silicon Mac should do at least this well.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Build time&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;th&gt;Tip&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Photo set (Object Capture)&lt;/td&gt;
&lt;td&gt;1–2 min for 24–37 photos&lt;/td&gt;
&lt;td&gt;The best: true shape, real colours, small details like logos and buttons&lt;/td&gt;
&lt;td&gt;The underside stays open unless you photograph it too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 photos (Hunyuan3D multi-view)&lt;/td&gt;
&lt;td&gt;About 35 min&lt;/td&gt;
&lt;td&gt;Good all round; back and sides follow your real photos&lt;/td&gt;
&lt;td&gt;Needs about 7 GB, so close every other app first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 photo (Hunyuan3D mini)&lt;/td&gt;
&lt;td&gt;About 6 min&lt;/td&gt;
&lt;td&gt;Good from the front and sides; the back is a guess&lt;/td&gt;
&lt;td&gt;Use a clear three-quarter view on a plain background&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 35 minutes for multi-view is the memory talking, not the GPU. On a 16 GB Mac it's much faster. More memory also buys more detail automatically: Macs with 16 GB or more get full-detail photo sets and 4K textures, without you changing a setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;You'll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An Apple Silicon Mac on macOS 14 or newer (an 8 GB M1 is enough)&lt;/li&gt;
&lt;li&gt;An iPhone or Android phone on the same Wi-Fi&lt;/li&gt;
&lt;li&gt;Node.js 22.13+ and pnpm 10+&lt;/li&gt;
&lt;li&gt;Xcode 16+ for iOS, Android Studio for Android&lt;/li&gt;
&lt;li&gt;Ollama for the capture tips, and ComfyUI only if you want the two AI modes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Install&lt;/span&gt;
pnpm &lt;span class="nb"&gt;install&lt;/span&gt;

&lt;span class="c"&gt;# 2. Get the model for capture tips (keep the Ollama app running)&lt;/span&gt;
ollama pull qwen3.5:4b

&lt;span class="c"&gt;# 3. Check that everything is set up (it tells you how to fix what's missing)&lt;/span&gt;
pnpm check-setup

&lt;span class="c"&gt;# 4. Start the Mac gateway and the app's dev servers&lt;/span&gt;
pnpm start &lt;span class="nt"&gt;--real&lt;/span&gt;

&lt;span class="c"&gt;# 5. In a second terminal, run the app&lt;/span&gt;
pnpm pods &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pnpm ios      &lt;span class="c"&gt;# iPhone simulator&lt;/span&gt;
pnpm android               &lt;span class="c"&gt;# Android over USB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a real iPhone, start the dev servers with &lt;code&gt;pnpm start --real --device&lt;/code&gt; so the phone can reach them over Wi-Fi, and run from Xcode. Then on the phone go to &lt;strong&gt;Profile → Your Mac → Find Macs on this Wi-Fi&lt;/strong&gt; and type the 6-digit code the gateway prints in its terminal.&lt;/p&gt;

&lt;p&gt;One thing I'm glad I added: if you don't have any AI installed yet, plain &lt;code&gt;pnpm start&lt;/code&gt; (without &lt;code&gt;--real&lt;/code&gt;) runs a fake gateway that still produces real 3D files. You can click through the whole app before downloading a single model. And &lt;code&gt;pnpm check-setup&lt;/code&gt; checks Node, Ollama, ComfyUI, Object Capture, the gateway and your Wi-Fi address, and tells you the exact fix for anything that's off.&lt;/p&gt;

&lt;p&gt;The repo also comes with about 400 Jest tests, 39 C++ tests and Detox end-to-end tests, plus setup, architecture, technology and security guides in the &lt;code&gt;docs/&lt;/code&gt; folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  That's it
&lt;/h2&gt;

&lt;p&gt;I wanted to know how far you can get with the hardware that's already on your desk. Pretty far, it turns out. A phone that's fussy about its photos and a base-model Mac that's careful with its memory can turn a real object into something you can spin around, drop on your floor and send to a friend. None of it touches the cloud.&lt;/p&gt;

&lt;p&gt;The code is all on GitHub: &lt;a href="https://github.com/neel-turbo/app-image-to-3d-local-ai-rn" rel="noopener noreferrer"&gt;neel-turbo/app-image-to-3d-local-ai-rn&lt;/a&gt;. If you try it, I'd love to see what you scan. If something breaks, open an issue and tell me what you were pointing the camera at.&lt;/p&gt;

&lt;p&gt;And if this was useful, a star on the repo helps other people find it.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ai</category>
      <category>showdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
