<?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: Maciek J</title>
    <description>The latest articles on DEV Community by Maciek J (@mjarosie).</description>
    <link>https://dev.to/mjarosie</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%2F479598%2F76682f85-8b51-49cd-be45-7e15151089d0.jpeg</url>
      <title>DEV Community: Maciek J</title>
      <link>https://dev.to/mjarosie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mjarosie"/>
    <language>en</language>
    <item>
      <title>Art exposition manager</title>
      <dc:creator>Maciek J</dc:creator>
      <pubDate>Sun, 04 Oct 2026 22:34:17 +0000</pubDate>
      <link>https://dev.to/mjarosie/art-exposition-manager-2lbh</link>
      <guid>https://dev.to/mjarosie/art-exposition-manager-2lbh</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;My girlfriend is an analogue collage artist. She cuts up old and new magazines and comes up with all sorts of weird and wonderful pieces that are full of colour and humour. She exhibits them in cafés and libraries around Madrid.&lt;/p&gt;

&lt;p&gt;The problem with getting her work onto a wall is another job entirely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;finding places that actually host exhibitions,&lt;/li&gt;
&lt;li&gt;writing a personalised email to each,&lt;/li&gt;
&lt;li&gt;remembering which piece fits which space,&lt;/li&gt;
&lt;li&gt;keeping track of who said what in which email thread.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;She does all of this by hand, from her private Gmail and an Excel sheet.&lt;/p&gt;

&lt;p&gt;To make it easier for her to manage the whole process, I built an &lt;strong&gt;Art Exposition Manager&lt;/strong&gt;, an app that takes her from "I'd like to exhibit my work at that library" to a ready-to-send email:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;She adds a venue&lt;/strong&gt;, possibly with just a name and a neighbourhood. The app looks it up on OpenStreetMap and the web, reads the venue's own site, and builds a profile: what kind of place it is, the atmosphere and audience, whether they host shows, past exhibitions, a contact email if one is published. When several places match, she picks the right one. Every fact shows where it came from, and anything the app couldn't find is listed as missing, never made up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It picks 5–8 of her works&lt;/strong&gt; that suit that venue, and explains each choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It drafts the outreach email&lt;/strong&gt; in the venue's language (Spanish, Catalan or English) and in &lt;em&gt;her&lt;/em&gt; voice, using a few emails she has actually sent as examples.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It saves an exposition candidate&lt;/strong&gt;, which moves from &lt;code&gt;prospect&lt;/code&gt; to &lt;code&gt;pitched&lt;/code&gt; once she sends it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;She can also do all of this from &lt;strong&gt;Telegram&lt;/strong&gt;: "add the X library in the Y neighbourhood of Madrid", "what's the status with Café XYZ?".&lt;/p&gt;

&lt;p&gt;The AI drafts, and she modifies as needed. Nothing is ever sent to a venue automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting it to the test
&lt;/h3&gt;

&lt;p&gt;After the MVP was built, we went for a walk in Madrid city centre, to find a real venue she'd consider. I handed my mobile to her (she didn't have Telegram installed), and when we found an interesting coffee shop we passed by, she sent a message to the bot. It replied asking about confirmation and afterwards stored the venue.&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%2Ffc5ul5xbq82mgqkdqdpt.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%2Ffc5ul5xbq82mgqkdqdpt.png" alt=" " width="580" height="786"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;She was quite amazed by the fact that the evening before we were just chatting about the idea of building it, and the next afternoon it was ready to be tested, and we could chat with it through Telegram.&lt;/p&gt;

&lt;p&gt;What turned out not to work quite right was the bot (with Hermes Agent - the AI chat framework behind it) trying to write the draft itself, instead of invoking the API that would then do it as part of the application logic. But that was quite easy to address later via prompt and MCP updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;The app keeps basic track of the artworks:&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%2Fghq2245mshjdryntcioh.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%2Fghq2245mshjdryntcioh.png" alt=" " width="800" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It keeps track of venues where expositions can potentially take place:&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%2Fjxnba7r0vfp10fr4cv6l.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%2Fjxnba7r0vfp10fr4cv6l.png" alt=" " width="800" height="188"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Adding the venue even with as little as a name and a city will store the details and look up the rest via web search. Over time you can keep the details about the venue up to date:&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%2Fzyto7uoqzo01laqz9m3s.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%2Fzyto7uoqzo01laqz9m3s.png" alt=" " width="781" height="838"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You click a button to generate a "pitch":&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%2Fjnlnqfju2k11t2nblcbg.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%2Fjnlnqfju2k11t2nblcbg.png" alt=" " width="799" height="204"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And then in a minute or so you get a draft of an email, and a set of suggested works to be exposed, with an explanation of why a certain piece should end up in this exposition:&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%2Fh242xgv74w5vv3ov20bw.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%2Fh242xgv74w5vv3ov20bw.png" alt=" " width="799" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://gitlab.com/mjarosie/exposition-manager" rel="noopener noreferrer"&gt;gitlab.com/mjarosie/exposition-manager&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Her originals, the database and her emails are git-ignored. The repo only contains synthetic test fixtures.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;After having an initial chat with the target user (the artist!), I had a brief chat with Claude, who helped me brainstorm the ideas on what features should be included in the initial MVP (I didn't have much time to work on it, so I had to cut down scope significantly).&lt;/p&gt;

&lt;p&gt;Then I've built the service using Claude Code.&lt;/p&gt;

&lt;p&gt;Everything runs on my local machine. There are no paid APIs anywhere in the loop.&lt;/p&gt;

&lt;p&gt;The diagram below shows a high-level overview of the architecture of the application. For now it's only deployed locally, so can't share the link to the live demo.&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%2Fga4rtorb989w5am98egg.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%2Fga4rtorb989w5am98egg.png" alt="Service architecture" width="799" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model: Gemma 4 26B-A4B&lt;/strong&gt;, 4-bit, served by &lt;code&gt;llama.cpp&lt;/code&gt;'s &lt;code&gt;llama-server&lt;/code&gt; on my Mac's GPU, using its OpenAI-compatible API. It's a mixture-of-experts model with about 4B parameters active per token, so it's fast enough for interactive use. It also accepts images, so one model covers both analysing the artwork and writing emails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent framework: Mastra.&lt;/strong&gt; I'd never used it before, so that was an interesting learning experience. Each AI task is a Mastra agent with a Zod schema for its output:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;artwork-describer&lt;/code&gt;: looks at each collage once and writes a description, motifs, palette, mood, likely source materials, and an explanation of the title's wordplay&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;venue-profiler&lt;/code&gt;: turns a venue's website, photos and notes into a structured profile&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;work-picker&lt;/code&gt;: chooses works for a venue and explains why&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pitch-writer&lt;/code&gt;: writes email drafts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are chained into Mastra workflows. The non-AI steps live in the workflows too: fetching pages, OpenStreetMap lookups, and a code-only check on every draft that rejects placeholders like &lt;code&gt;[Name]&lt;/code&gt;, wrong languages, drafts that don't mention the venue, and drafts outside 120–350 words. If a draft fails the check, it's regenerated once with the reasons attached.&lt;/p&gt;

&lt;p&gt;The vision pass was the part I was most curious about. Here's what Gemma wrote about one of her pieces, &lt;em&gt;Viaja por un ojo de la cara&lt;/em&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The title is a play on the Spanish idiom 'costar un ojo de la cara' (to cost an arm and a leg). The collage literalizes the phrase by showing a creature physically interacting with the woman's eye.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those descriptions are what make the pitches specific. The picker doesn't choose "work #12", it chooses "the muted blue one that suits a reading room".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Venue research.&lt;/strong&gt; Web search goes through a self-hosted &lt;strong&gt;SearXNG&lt;/strong&gt; container, and place lookup through &lt;strong&gt;OpenStreetMap's Nominatim&lt;/strong&gt;. Only the venue's name, neighbourhood and city ever leave the machine. The local model decides which search results are the right venue, and it may only return links that actually appeared in those results. Research only fills empty fields and records a source for each one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A chat front end: Hermes Agent&lt;/strong&gt; (Nous Research), running in Docker with a Telegram gateway. The app exposes its features as an &lt;strong&gt;MCP server&lt;/strong&gt;, and Hermes talks to the same local Gemma.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tracing: Arize Phoenix&lt;/strong&gt;, through Mastra's &lt;code&gt;@mastra/arize&lt;/code&gt; OpenTelemetry exporter. Every agent call, workflow step and LLM request shows up as a span, tagged with &lt;code&gt;prompt_version&lt;/code&gt;, whether thinking was on, and the venue/artwork/exposition IDs. With a local model you're tuning prompts constantly, and being able to see exactly what Gemma received (including the image) is what makes that workable.&lt;/p&gt;

&lt;p&gt;One of the first things I noticed was that the model was not always making use of the tools, especially when researching venues. I decided to make use of Mastra's &lt;code&gt;createWorkflow&lt;/code&gt; to guide the agent through a more predefined set of steps for achieving it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; TypeScript end to end. Hono + Vite/React, SQLite via Drizzle, &lt;code&gt;sharp&lt;/code&gt; to shrink her 5000–7000 px scans to 1024 px before the model sees them. Everything except the model runs in Docker Compose (app, Phoenix, SearXNG, Hermes). I decided to run the model on the host, because I couldn't make Docker on macOS make use of the GPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I worked with my coding agent.&lt;/strong&gt; I planned milestones with Claude, then handed it over to Claude Code. Every acceptance criterion had to have a test before a milestone counted as done. Because LLM output changes from run to run, the tests come in layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;unit and API tests run against a &lt;strong&gt;mock OpenAI-compatible server&lt;/strong&gt; with fixed fixtures,&lt;/li&gt;
&lt;li&gt;Playwright drives the real UI (also against the mock),&lt;/li&gt;
&lt;li&gt;a separate &lt;code&gt;@live&lt;/code&gt; suite runs against the real Gemma and checks &lt;em&gt;properties&lt;/em&gt;, not exact text: valid schema, correct language, no invented emails, the venue mentioned, real work IDs. Results are saved as JSON so prompt and model changes can be compared.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This set of tests was what let the agent iterate on its own and know when a feature actually worked.&lt;/p&gt;

&lt;h3&gt;
  
  
  Things I learned the hard way
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thinking mode should be set per request, not per server.&lt;/strong&gt; Gemma 4 can "think" before answering. Thinking off is faster and safer for JSON extraction, but it helps when the model is judging which works fit a venue. llama-server accepts &lt;code&gt;chat_template_kwargs.enable_thinking&lt;/code&gt; on each request, so every agent sets its own value. With &lt;code&gt;--reasoning-format deepseek&lt;/code&gt;, the reasoning comes back in a separate field and never breaks JSON parsing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fewer tools make a smarter agent.&lt;/strong&gt; With Hermes's full default toolset, the 26B model would ignore my app and improvise with file access and web search. Cutting it down to the app's MCP tools plus &lt;code&gt;clarify&lt;/code&gt;, &lt;code&gt;memory&lt;/code&gt; and &lt;code&gt;vision&lt;/code&gt; fixed that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Never invent contact details" needs code behind it.&lt;/strong&gt; The prompt says it, but what actually guarantees it is the rule that links must come from the search results and unknown fields stay &lt;code&gt;null&lt;/code&gt;, plus a live test that checks it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local is slower.&lt;/strong&gt; A full pitch (pick works + write the draft) takes about 45s on the laptop, and venue research about 40s. For writing to a venue, which isn't a real-time task, that's fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;First, more of a general thought: before the LLM boom you used to be able to build anything with just a laptop and internet access, but that slowly stops being the case. Developers get more and more accustomed to the speed at which the AI writes code, and sometimes take it to extremes, where planning and architecture are completely left to the model itself (which I personally don't find to be a good long-term practice). As a result, unless you can afford running a GPU rack in your basement, you're more and more dependent on services of Anthropic, OpenAI, SpaceXAI's Cursor etc. Being able to spin up a capable model on a laptop, with no API keys, no usage meter and no data leaving the machine, gives that independence back, but I think we still have to reach the point where it's nice and easy to use them to work on complex programming tasks.&lt;/p&gt;

&lt;p&gt;Open innovation lets others learn too - I would not be able to learn how to serve an LLM if it wasn't for the fact that there are so many tools available for easily downloading and spinning one up, which I really appreciate - we're truly standing on the shoulders of giants.&lt;/p&gt;

&lt;p&gt;Some other things open made possible, specific to this little weekend project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero running cost.&lt;/strong&gt; Once the model is downloaded, every pitch, redraft and vision pass is free, so I could run all 46 artworks through the vision model and regenerate drafts as often as required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swappable by design.&lt;/strong&gt; Every model call goes through one factory driven by four env vars. If Gemma 4 26B isn't good enough for some task, the 31B is one line away, and so is an open-weight model on a rented GPU. The &lt;code&gt;@live&lt;/code&gt; evals tell me whether the switch actually helped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Room to grow.&lt;/strong&gt; The next step is fine-tuning a small model on my girlfriend's own writing so drafts sound even more like her. That's only possible because the weights are open.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To be honest about where it was weaker: a frontier closed model would write a slightly more polished first draft, and faster. But every draft would be edited anyway, and good example emails matter more for quality than model size does. So the trade was easy.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;The planning conversation, from brain dump to MVP plan: &lt;a href="https://gitlab.com/mjarosie/exposition-manager/-/blob/main/docs/PLANNING_SESSION.md" rel="noopener noreferrer"&gt;PLANNING_SESSION.md&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;: Gemma 4 26B-A4B runs locally for everything: vision enrichment of her catalogue, venue profiling, work selection, email drafting, and the Telegram agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Mastra&lt;/strong&gt;: agents with Zod structured output and workflows that orchestrate an open model, with Phoenix tracing through &lt;code&gt;@mastra/arize&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
    </item>
  </channel>
</rss>
