Monolyth: what it does and how it is built
Monolyth is the control centre of my day on the Mac. One window brings together what used to be spread over a dozen tabs: important email from several accounts, domains about to expire, orders and money from several print-on-demand shops, visits to each website, tasks with their time on the calendar, a journal and a habit tracker. All of it can also be run from Telegram, away from the Mac.
I built it because I could not find anything like it. There are good apps for each of those things, but none that connects them. Finding out whether a paid order already has its print job, or what it really leaves after fees, means opening Etsy, Stripe, the print provider and email: four services that do not talk to each other.
It comes to 12,852 lines of Swift, 4,501 of Python and two Cloudflare Workers. It builds without Xcode and uses no third-party packages.
1. What is inside
Today is the first screen and answers one question: what needs attention today. It shows urgent orders, unread important email, what will be charged in the next seven days, pending tasks from each project and domains that expire within 30 days and do not renew on their own.
Orders and Money run the shops. Business shows one card per website with visits, Google searches, domain expiry and the month's sales. Tasks, Habits, Journal, Progress and Life are the personal side. Health checks the Mac itself: security, backups, disk, battery and updates. Projects keeps the list of code projects and their private copy on GitHub. Etsy Studio and Etsy Autopilot make the Etsy mockups and listings (section 8).
The Telegram bot runs on the same Mac and has 51 commands: it marks habits, adds dated tasks, writes in the journal, saves notes, sets reminders, transcribes voice notes, sends screenshots or files, and can lock the Mac or put it to sleep. When the Mac is off, a Cloudflare Worker keeps watching the websites, the domains and the orders, and alerts through the same chat.
2. Two processes
The interface is SwiftUI. Every data read is a Python script that runs, prints JSON and exits.
The contract between the two sides is small. Only JSON goes to standard output. An error is {"error": "…"}, and Swift tries to decode it before the expected type:
struct HelperFailure: Decodable { let error: String }
let decoder = JSONDecoder()
if let failure = try? decoder.decode(HelperFailure.self, from: data) {
return .failure(HelperError(message: failure.error))
}
return .success(try decoder.decode(type, from: data))
Keys go in through standard input, because an argument shows up in the process list, and they are stored in the Keychain. The scripts use only the standard library of the Python 3.9 that ships with macOS.
Building without Xcode has one trap in the macOS 27 SDK: @State and Swift Charts rely on macros whose plugin only ships with Xcode. @State is avoided by naming the type, typealias Local<Value> = SwiftUI.State<Value>, and the charts are drawn by hand with shapes.
3. Orders
Each sale is matched with its print job: same recipient and country, created after the sale and within 30 days, and each job used once. Anything paid without a job goes to the top in red.
The buyer's name only exists in memory for this match. It is removed from the record before it leaves the module and is never written to disk.
An order marked as shipped on Etsy stays on the list until the print provider actually ships it. Sometimes it gets marked early, and then it used to drop out of sight.
With Etsy's API, Monolyth also writes. A button adds the print provider's tracking number to Etsy orders that lack one: for orders already marked as shipped, once the parcel has left; for the rest, as soon as the number exists, so they count as shipped before the deadline. Etsy only recognises some carriers by name, and the others are sent with the name written out, which is what choosing "Other" by hand does. Each number is checked by re-reading the order. The same access was used to add a size that half of the listings were missing: each listing's inventory is read, a copy is saved, and the whole inventory is written back with the new size.
4. Money
An order's profit is what was paid, minus platform fees, minus the print cost, minus refunds. Fixed costs are spread per day according to how often they are charged. The calculation is a pure function with no network or keys, so it is tested end to end without mocking anything. The app has 136 tests.
Etsy's fees come from its payment ledger. Connecting it brought out three bugs that test data never showed:
- Etsy will not return more than 31 days of the ledger at once. It is now requested in 30-day steps.
- The credit for each sale and its processing fee carry the payment number, not the order number. Without linking them, the tax the buyer pays, which Etsy passes on to the tax authority, was counted as a fee, and refunds were subtracted twice. Over six months, profit came out about a third lower than it really was. Etsy is now asked which order each payment belongs to.
- Credits from the platform were not subtracted anywhere.
With payments linked, an order's fees are one sum:
# Paid by the buyer, passed on to the tax authority:
# neither income nor a fee.
ETSY_NO_COMISION = ("PAYMENT_GROSS", "sales_tax", "sales_tax_refund")
cobrado = round(-sum(i for i, _, t in libro
if t not in ETSY_NO_COMISION), 2)
And the ledger is requested in steps, with no overlap:
while inicio < ahora:
fin = min(inicio + TRAMO_LIBRO_ETSY, ahora) # 30 days
apuntes += pedir_paginas(inicio, fin)
# Both ends count: the next step starts one second later.
inicio = fin + 1
Since the shop's first order, the Etsy charges Money adds up and the ledger's total differ by 0.1%. Before the API was available, sales were read from the sale notifications over IMAP and fees were estimated. Compared with the 43 payouts Etsy made to the bank account, that model was within ±2%.
The print provider's API gives quotes without creating orders and, for each real order, splits print, shipping and VAT. That showed something the totals hid: an order to the European Union costs on average 77% more than the same order to the United States, because of shipping and VAT. Two centimetre sizes looked expensive to produce and were the same product as their inch equivalents; they were simply bought by people in Europe.
5. The day ends at four
Something logged at half past one counts for the day before. If today's tasks turned overdue at midnight, the list would be wrong every night. The day is the date in Madrid minus four hours, and the same rule is written in Swift, Python and JavaScript, because each piece decides on its own what day it is. In Swift:
static let cutoffHours = 4.0
static func day(_ date: Date = Date()) -> String {
let shifted = date.addingTimeInterval(-cutoffHours * 3600)
// calendar is set to Europe/Madrid
let c = calendar.dateComponents([.year, .month, .day],
from: shifted)
return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
}
6. Tasks, habits and journal
A task is written in one line: "frame the order fri 10:00 #shops", "el 1 de cada mes", "in 2 weeks". The parser understands Spanish and English, compares without accents and cuts the date out of the original text, so the title keeps its accents. Dated tasks go to the calendar through EventKit, using the accounts the Mac already has. Google Tasks was ruled out because it keeps the day and drops the time. EventKit's identifier can change when syncing, so each event carries the task id in its notes to be found again.
Habits live in a Cloudflare Worker with a Durable Object in SQLite, behind Cloudflare Access. The Worker checks the Access JWT on every request. The app and the Telegram bot come in through another door with a key compared in constant time, so marking a habit from the Mac, the phone or Telegram is the same thing. timingSafeEqual needs two inputs of the same length; hashing both with SHA-256 first solves that and does not reveal how long the real key is:
export async function esElBot(env, request) {
const dada = request.headers.get("X-Bot-Key") || "";
const buena = String(env.BOT_KEY || "");
if (buena.length < 32) return false;
const [a, b] = await Promise.all([dada, buena].map(
(s) => crypto.subtle.digest("SHA-256", enc(s))));
return crypto.subtle.timingSafeEqual(a, b);
}
The journal is one Markdown page per day, saved automatically 1.2 seconds after typing stops. The app writes the first line: habits, sales and tasks closed that day. Words with a hash are tags in every section. Colouring them while typing took an AppKit NSTextView, because SwiftUI's editor cannot colour part of the text. The bot can add a paragraph to today's page from Telegram; if someone is typing in the app at that moment, saving merges both texts instead of overwriting one.
Progress compares the current week with the same days of the previous four: on a Friday, against full weeks, everything looked worse.
All of this is stored in one folder of JSON and Markdown files, written atomically. An assistant can read it without connectors, and the rules for doing so are written in the folder itself.
7. The notch and the watchdog
The notch panel is an NSPanel that fills exactly the gap between the screen's two side areas. macOS provides those two areas, so the notch size is never guessed:
let withNotch = NSScreen.screens.first { $0.safeAreaInsets.top > 0 }
guard let screen = withNotch,
let left = screen.auxiliaryTopLeftArea,
let right = screen.auxiliaryTopRightArea else { return }
let height = screen.safeAreaInsets.top
closedFrame = NSRect(x: left.maxX, y: screen.frame.maxY - height,
width: right.minX - left.maxX, height: height)
An NSTrackingArea reports when the pointer enters or leaves, with no timer. Open, it is a small dashboard: what is waiting for a reply (Etsy messages and important email), the latest sales with what each one left, the day's and month's earnings, today's habits to tick off right there, shortcuts and a field to add a task without opening the app. When a sale comes in, it opens by itself for a few seconds.
With the Mac off, a free Worker keeps watch. Every five minutes it checks the websites (an outage is declared after two failed rounds) and paid orders without a print job; once a day, domain expiry. It alerts through Telegram.
8. From an image to an Etsy draft
Every new poster needed twenty mockups, a title, thirteen tags, a description and alt text, and then the listing had to be filled in by hand. Now the image is dropped into Etsy Autopilot and ends up as an Etsy draft.
The mockups used to come from a Photoshop script. Monolyth reads the PSD itself: the layers with their pixels and, for each smart object, the four corners of its frame. It then composites the layers in order and warps the design onto those corners with the layer's blend mode. Almost every frame multiplies, which is what lets the glass reflections show over the poster:
func p(_ i: Int) -> CIVector {
CIVector(x: q[i], y: height - q[i + 1])
}
let warped = art.applyingFilter("CIPerspectiveTransform",
parameters: ["inputTopLeft": p(0), "inputTopRight": p(2),
"inputBottomRight": p(4), "inputBottomLeft": p(6)])
canvas = warped.applyingFilter("CIMultiplyBlendMode",
parameters: [kCIInputBackgroundImageKey: canvas])
The composite image stored in the PSD can't be the background: in many mockups it has the frame's sample content painted in, and multiplying over it tints the design. The corners aren't enough either. 239 of the 776 mockups carry a mask on the frame that keeps fingers, a lamp or a leaf in front of the poster; the first version ignored it and the design covered the hand. And reading the design already scaled to 3,000 px with ImageIO took each batch from 15 seconds to under one.
An AI writes the texts from the image, with no notes. Monolyth calls the AI from the command line once per poster, from scratch each time and in a temporary folder, with a fixed response schema. Started normally it loaded 116,000 tokens of context it didn't need (connectors, plugins, skills). In safe mode and with its own instructions it uses about 8,000 per poster:
subprocess.run([ai, "-p", pedido, "--safe-mode",
"--strict-mcp-config", "--disable-slash-commands",
"--system-prompt", SISTEMA,
"--json-schema", json.dumps(ESQUEMA),
"--tools", "Read", "--output-format", "json"])
Before anything is uploaded it checks the rules Etsy publishes in its specification: a title of at most 140 characters, using %, :, & and + once each, and thirteen tags of up to 20 characters with only letters, digits, spaces, hyphens and apostrophes. Etsy writes them with Unicode categories (\p{L}, \p{Nd}), which Python's re module doesn't understand, so they are checked with unicodedata. The first check of a 196-listing CSV already found a tag with a "+".
What every listing shares (category, shipping profile, returns, processing time and the fifteen sizes with their prices) is copied from a listing that acts as a template. The listing is born as a draft and is only activated at the end, if asked; if something fails halfway, it stays a draft. When it's done it is read back from Etsy to check it.
9. Bugs found in use
On reinstall, the new version started while the old one's bot was shutting down, took it as alive and did not launch its own. It now waits for the old one to exit.
Arguments reached the scripts in decomposed Unicode: "año" with the accent as a separate character, and the comparison failed. The scripts normalise to NFC.
The print provider returned 403 to Python with a valid key, and the habit site gave Cloudflare's error 1010: Cloudflare blocks urllib's client signature. Sending a custom User-Agent fixes it. The connection check showed green because it did not make the same request as real use.
On macOS, if a view asks for more height than the window has, the content spills out at the top and the header and sidebar disappear. Capping that view's minimum height at zero fixed it.
Opening a new screen froze the whole app without using any CPU. sample showed the main thread stuck in an open() of the mockups folder, inside Documents: after every reinstall macOS asks for that permission again, and the call waits for the answer. No view reads the disk while it draws any more.
A correct draft came out as "not checked". Etsy returns texts with HTML entities (Children's), and compared with the title that was sent it didn't match. They are now compared decoded.
10. How it is checked
Any screen opens without a mouse: launch options pick the section, the detail and the window size, and hub:// links open a specific item. A demo mode fills every view with made-up data, says so on screen and does not touch the disk or the bot. Every change is reviewed with screenshots of the window.