# Ilham Riski Wibowo · Backend Developer > I build web applications, chatbots, and APIs. Full content of https://ilhamriski.com, in one file. Source: https://ilhamriski.com/ · Generated from the site's own content on 2026-09-03. A shorter index is available at https://ilhamriski.com/llms.txt. ## Profile Backend Developer with 4+ years of experience building web applications, chatbots, and APIs. My main stack is Laravel and PHP, backed by Python and JavaScript/TypeScript, and I'm currently exploring Rust. I care about efficient, scalable solutions and shipping open-source tools that people actually use. - Name: Ilham Riski Wibowo - Also known as: IlhamriSKY (GitHub handle, used across his open-source work) - Role: Backend Developer - Also works as: Fullstack Developer, Web Developer, Chatbot Developer - Location: Semarang, Indonesia - Availability: open to work and freelance projects ## Contact - GitHub: @IlhamriSKY · https://github.com/IlhamriSKY - WhatsApp: +62 813-9119-2037 · https://wa.me/6281391192037 - Email: ilhamriskiwibowo@gmail.com - LinkedIn: ilhamriski · https://www.linkedin.com/in/ilhamriski - Instagram: @buildbyilham · https://www.instagram.com/buildbyilham - Website: https://ilhamriski.com ## What he builds ### 01 · Backend development Robust web apps and services with Laravel & PHP, clean architecture, auth, queues, and databases that scale. Focus: Backend ### 02 · API design & wrappers REST APIs and integration wrappers, including community wrappers for the PDDIKTI Kemdikbud data used across Indonesia. Focus: APIs ### 03 · Chatbots & automation WhatsApp/Telegram chatbots, schedulers, and automation that remove the repetitive parts of a workflow. Focus: Chatbots ### 04 · Packages & extensions Published packages and VS Code extensions on Packagist, PyPI, and the Marketplace, reused by other developers. Focus: Open Source ### 05 · Full-stack platforms End-to-end platforms: admin dashboards, alumni networks, and data tools from database to interface. Focus: Web Apps ### 06 · Data & web scraping Reliable scrapers and data pipelines that collect, clean, and serve structured data from messy sources. Focus: Data ## Experience ### Full-stack Developer · Karangturi School Nov 2024 – Present · Semarang, Central Java · On-site - Building and maintaining web applications and internal systems for the school. - Full-stack work with Laravel, PHP, and JavaScript. ### Back End Developer · Decodes Media Sep 2023 – Mar 2024 · Indonesia · Remote · Part-time - Backend and API development for client web products. - Worked primarily with PHP and REST API integrations. ### Full-stack Developer · Unika Soegijapranata Semarang Jan 2023 – Oct 2023 · Indonesia · Full-time - Built and maintained campus web apps and internal tools with PHP/Laravel and Python. - Delivered the Halo Alumni (HANI) network, tracer-study data systems, and link/QR tools. ## Selected work ### TEDI My flagship: a lightweight desktop app that folds eight everyday dev tools (terminal, SSH/SFTP, a database browser, a code editor, an AI agent, an in-app browser, an API client, and Git) into one ~7 to 10 MB window built on Tauri 2. No telemetry, and this very site borrows its design system. TEDI (Terminal Director) folds eight tools a developer reaches for every day into a single window, so the working day stops being a loop of alt-tabbing between a terminal, an SSH client, a database GUI, an editor and a browser. It is built on Tauri 2, which means a Rust core owns every OS resource and the interface is one native webview. There is no Node runtime and no bundled Chromium, so the download is roughly 7 to 10 MB and the resident footprint sits closer to a terminal than to an IDE. It runs on Windows, macOS and Linux, ships no telemetry, keeps API keys in the OS keychain rather than on disk, and will work entirely offline against a local model. TEDI is Apache-2.0 and is a fork of Crynta's Terax (v0.5.9): the Tauri and Rust backend, the xterm.js terminal, the CodeMirror editor and the agent pipeline come from that project, and TEDI builds its own release line, extension system, CLI and tooling on top. It is my most-starred repository, and this portfolio borrows its design system. What it does: - Terminal multiplexer: Native PTY terminals (zsh, bash, fish, pwsh) on xterm.js with WebGL: split panes, tab groups, shell integration (OSC 7 and 133), inline search and link detection. Every workspace keeps its own layout and working directories and reopens exactly where you left it. - SSH and SFTP: Saved connections over russh, remote shells, and an integrated file explorer for browsing and transferring, all from one connection manager. - Editor and source control: CodeMirror 6 for TypeScript, Rust, Python, PHP, SQL and a dozen more, with inline AI autocomplete, a diff view, Vim mode and Markdown preview, next to an inline Git diff and SCM pane. - An agent with your own key: Eleven providers (OpenAI, Anthropic, Google, xAI, Groq, DeepSeek and more) plus local models through LM Studio, Ollama, llama.cpp or vLLM. Plan mode, sub-agents, MCP, project memory in TEDI.md, and every tool gated behind approval. - A browser the agent can drive: A real in-app webview it can navigate, type into, click, scroll, screenshot to actually see, and read console errors from when a page breaks. - Runtime extensions: Nothing is baked into the binary. The SQL explorer and the Postman-style API client are extensions, installed from a .zip or straight from a GitHub release, and the same path handles updates. - Page: https://ilhamriski.com/tedi/ - Category: Open Source · Desktop App - Tech: Tauri, Rust, TypeScript, React, Vite, Tailwind CSS - Tags: Tauri 2, Rust, TypeScript, React 19 - Live: https://tedi.ilhamriski.com - Source: https://github.com/IlhamriSKY/TEDI - GitHub stars: 155 - Latest version: v0.4.40 - Video: https://youtu.be/x_qWOaYqnxU - Screenshot: https://ilhamriski.com/tedi/tedi1.png ### PokeHub Turns any public GitHub profile into a holographic Pokémon-style trading card. Nothing is picked by hand: followers become HP, stars become attack damage, public repositories set the retreat cost, and your top language chooses the element, while a language model writes the species name, the flavour text, and both attacks. Every card is also served as an SVG, GIF, or PNG you can drop straight into a README. PokeHub turns any public GitHub profile into a holographic trading card. Nothing on the card is chosen by hand: followers become HP, stars become attack damage, public repositories set the retreat cost, and the language you write most picks the element. A language model writes the species name, the flavour text and both attacks. No account is needed to generate a card. Signing in with GitHub claims the handle you authenticate as, which is what lets you regenerate it, restyle it, or take it down again. The same card is served in three formats, so it can go straight into a README: an SVG still, an animated GIF with the foil travelling under a highlight, and a PNG for link previews. Each one is a headless Chromium screenshot of the real card page on a transparent surround, so it suits a light or a dark README. The first request for a card takes a few seconds, every one after that is instant. It runs on Laravel 12, Inertia, React 19, Tailwind v4 and shadcn/ui, is GPL-3.0, and every integration (GitHub token, OAuth sign-in, the AI copy, the captcha) is optional and off by default. It is an unofficial fan project with no affiliation to Nintendo, The Pokemon Company or GitHub. What it does: - Your stats are the card: Followers to HP, stars to attack damage, public repositories to retreat cost, and your most-written language to the element. - Written, not templated: A language model names the species and writes the flavour text and both attack descriptions from the same profile data. - Three formats, one card: /username.svg for a README, /username.gif for the moving foil, /username.png for link previews. - Claim your handle: Generate anonymously, or sign in with GitHub to own the card and regenerate, restyle or remove it. - Page: https://ilhamriski.com/pokehub/ - Category: Open Source · Web App - Tech: Laravel, PHP, Inertia, React, TypeScript, Tailwind CSS, MySQL, Headless Chrome - Tags: Laravel 12, Inertia, React 19, GitHub API - Type: New - Live: https://pokehub.ilhamriski.com - Source: https://github.com/IlhamriSKY/PokeHub - Video: https://youtu.be/71vvcPnOfsk - Screenshot: https://ilhamriski.com/pokehub/pokehub1.png ### OTW-STORE · Multi-branch POS A point of sale with a warehouse behind it, for phone shops selling new and second-hand stock off the same counter: every handset tracked by its own IMEI, grade and battery health, across every branch, with installments, transfers and a full stock ledger underneath. OTW-STORE is a point of sale and a warehouse in one system, written for the kind of phone shop that sells a new iPhone and a traded-in Redmi across the same counter, in more than one town. The awkward part of that trade is that no two units are the same. A handset is not "one Galaxy A55" in stock: it is one specific handset, with its own IMEI, its own condition, its own grade from A to D, its own battery health and its own asking price. Every serialised unit is tracked on its own from the moment it is booked in, whether bought from a supplier or taken in part-exchange, until the moment it is sold, while accessories and chargers stay ordinary counted stock beside it. The counter is built for speed. Scan or type an IMEI and that exact handset lands on the bill; tap an accessory and it goes on by quantity. Payment takes cash, bank transfer, QRIS, debit and credit card, splits across several of them on one sale, and can leave a balance behind as an installment against a registered customer, aged against its due date from then on. Everything behind the counter is branch-aware. Stock is counted per branch and per warehouse with reserved and damaged units held apart, transfers move stock between branches and are followed until they are received, and every quantity change lands in a stock ledger with the reason and the person attached, so a count is a record rather than an opinion. The reports cover sales and profit, money, inventory and operations, each filterable and exported to Excel, with the cost-based ones behind their own permission. It is a Laravel, Vue.js and MySQL build. Like FitZone it is a product of mine rather than a client delivery: it is offered for sale as a working system, set up and adapted to the way a shop already runs. What it does: - Every handset by IMEI: A serialised unit carries its own IMEI, condition, grade from A to D, battery health, location, cost and asking price, not a quantity in a column. - A counter built for speed: Scan an IMEI and that exact handset is on the bill; accessories go on by quantity, with a discount per line or across the whole sale. - Payment however it arrives: Cash, bank transfer, QRIS, debit and credit card, split across several methods on a single sale. - Installments that get chased: Leave a balance against a registered customer, and it is aged against its due date until it clears. - Stock per branch and warehouse: Each location counted on its own, with reserved and damaged units held apart from what can actually be sold. - A ledger, not a guess: Every quantity change is recorded with why it happened and who caused it, so any stock figure can be explained afterwards. - Transfers between branches: Send stock to another branch and follow it until it is received, with anything dispatched but never arrived flagged. - Purchasing and trade-ins: Supplier orders and second-hand intake come in through one door and price themselves into the stock they create. - Reports that leave the system: Sales and profit, money, inventory and operations, each filtered and exported to Excel; the cost-based ones sit behind a permission. - One dashboard over all of it: Takings, outstanding balance, overdue accounts, stock value and the jobs waiting on someone, for one branch or for all of them at once. - Page: https://ilhamriski.com/otw/ - Category: Web App · Laravel - Tech: Laravel, PHP, Vue.js, Bootstrap, MySQL - Tags: Laravel, Vue.js, POS, Inventory - Type: For sale - Screenshot: https://ilhamriski.com/reels/otw-cover-w.png ### FitZone Gym · Management System A full gym management system: members and plans, billing and a point of sale, door check-in, class schedules, trainers, loyalty, and multi-branch control, all run from one dashboard. FitZone is a complete gym management system: everything a multi-branch club needs to run a day, from one dashboard. Members and plans sit at the centre, with billing and a point of sale attached, door check-in for arrivals, class schedules and trainer assignment, and a loyalty scheme on top. Branches are managed together rather than as separate installations, so plans, pricing and reporting stay consistent across locations. Around that core sit the parts a club actually spends its week on: a weekly timetable with trainers assigned to it and commission calculated on what they bring in, a shop with its own stock and pricing, coupons and day passes for people who are not on a plan, membership applications waiting to be approved, and reminders that chase a lapsing membership before it lapses. Roles and permissions decide who sees which of it: the front desk does not need the billing. It is a Laravel, Vue.js and Tailwind CSS build, and it is one of the two products here offered for sale as a ready system rather than delivered to a single client. What it does: - Members and plans: Membership records, plan assignment and renewals, with the member history behind them. - Billing and point of sale: Invoicing for memberships alongside a counter POS for everything else the club sells. - Door check-in: Arrivals recorded at the door and tied back to the member and their active plan. - Classes and trainers: Class schedules, capacity and trainer assignment, with bookings against them. - Loyalty: Points earned and redeemed as part of the same member record. - Multi-branch: Several locations run from one dashboard instead of one deployment each. - Trainer commissions: Coaching earnings calculated from the classes and sales attributed to each trainer. - Coupons and day passes: Discount codes for members, and passes for the people who are not on a plan at all. - Applications and reminders: New membership applications approved in the app, and renewals chased before the plan runs out. - Roles and permissions: Who can open billing and who can only work the door, decided per role rather than per person. - Page: https://ilhamriski.com/gym/ - Category: Web App · Laravel - Tech: Laravel, PHP, Vue.js, Tailwind CSS, PostgreSQL - Tags: Laravel, Vue.js, SaaS - Type: For sale - Screenshot: https://ilhamriski.com/reels/gym-cover-w.png ### PDDIKTI Kemdikbud API Unofficial Python 3 API wrapper to retrieve university, lecturer, and student data from PDDIKTI Kemdikbudristek. The community package, installable from PyPI. pddiktipy is an unofficial Python 3 wrapper for PDDIKTI, the Indonesian higher-education database, and it is the community package most people end up using: MIT licensed on PyPI, past 16,000 downloads, and my second most-starred repository. It exposes more than 70 endpoints (search, detail, statistics and visualisation) for students, lecturers, universities and study programmes behind one typed interface. Connection pooling, retries, input validation and custom exceptions are built in, so a caller gets a clear error instead of a mangled response, and the client works as a context manager for clean teardown. The two awkward parts of talking to PDDIKTI are handled for you: the search endpoint's payload is AES-256 encrypted and is decrypted transparently, and the Cloudflare clearance the site requires can be picked up from a local browser automatically. What it does: - 70+ endpoints: Students, lecturers, universities and study programmes, plus statistics and visualisation data. - Typed and validated: Full type hints for editor support, with input validation and custom exceptions rather than silent failures. - Built for real networks: Connection pooling and retries out of the box, and a context manager for clean resource handling. - Handles the hard parts: Transparent AES-256 decryption of the encrypted search endpoint, and automatic Cloudflare clearance from a local browser. - Page: https://ilhamriski.com/pddikti-api-python/ - Category: Open Source · Python - Tech: Python, PyPI, Cloudflare - Tags: Python, API Wrapper, PyPI - Source: https://github.com/IlhamriSKY/PDDIKTI-kemdikbud-API - GitHub stars: 122 - Latest version: v2.1.3 - Downloads: 16,000+ - Screenshot: https://ilhamriski.com/works/pddikti-python.jpg ### Simple DepthMap 3D Animation Turns 2D images into 3D animations using depth maps, layered parallax with zoom and perspective shifts, rendering high-quality 3D videos from a single photo. A small Python tool that turns a single flat photograph into a 3D animation. It runs depth prediction over the image to produce a depth map, splits the result into layers, and animates them against each other so nearer layers move further than distant ones. That parallax, plus zoom and perspective shifts, is enough to read as real camera movement through a still photo. Settings are adjustable per render and the output goes out as a high-quality video through FFmpeg, which makes it useful for turning an ordinary photo into something that moves. What it does: - Depth from one photo: Depth prediction over a single image, no stereo pair or depth sensor needed. - Layered parallax: The depth map split into layers that move at different rates as the virtual camera does. - Zoom and perspective: Camera moves configured per render rather than fixed to one effect. - Video out: High-quality video rendered through FFmpeg, ready to post. - Page: https://ilhamriski.com/depthmap-3d/ - Category: Open Source · Python - Tech: Python, OpenCV, NumPy, FFmpeg - Tags: Python, Computer Vision, FFmpeg - Source: https://github.com/IlhamriSKY/Simple-DepthMap-3D-Animation - GitHub stars: 22 - Screenshot: https://ilhamriski.com/works/depthmap.jpg ### PDDIKTI API Laravel Package A Laravel package that wraps the PDDIKTI API, giving developers easy programmatic access to Indonesian higher-education data. Available on Packagist. The Laravel counterpart to the Python wrapper: the same PDDIKTI higher-education data, reachable from a Laravel application in one line. It installs from Packagist with composer require ilhamrisky/pddiktiapi and exposes a single Api class whose methods mirror the searches people actually run: searchAll across every category, then searchMahasiswa, searchDosen and searchPt for students, lecturers and universities, alongside the detail lookups behind them. Like the Python package it is an unofficial community wrapper, combining API wrapping with scraping where PDDIKTI offers no endpoint, and it is MIT licensed. What it does: - One class, one install: composer require, then call the Api class. No configuration to write before the first request. - The searches people run: searchAll for everything at once, plus dedicated student, lecturer and university searches. - Detail lookups: The follow-up calls that turn a search result into a full record. - On Packagist: Versioned and installable like any other Laravel package. - Page: https://ilhamriski.com/pddikti-api-laravel/ - Category: Open Source · PHP - Tech: PHP, Laravel - Tags: PHP, Laravel, Packagist - Source: https://github.com/IlhamriSKY/PddiktiApi - GitHub stars: 18 - Latest version: v1.0.1 - Screenshot: https://ilhamriski.com/works/pddikti-laravel.jpg ### Laravel Timezone by City A small Laravel package to retrieve timezone information for cities worldwide, backed by Carbon for date/time manipulation. Simple install, simple API. A deliberately small Laravel package that answers one question: given a city name, what timezone is it in? The dataset comes from the GeoNames Gazetteer and covered 146,892 cities at the last update, and Carbon does the date and time work on top of it. The API is three or four methods: check whether a city exists, list the cities available, and pull the full record for one of them. It exists because the alternative in most projects is a hand-maintained array of a dozen cities that quietly goes wrong the moment someone outside them signs up. What it does: - GeoNames data: 146,892 cities worldwide as of the February 2024 dataset, not a hand-kept list. - Carbon underneath: Timezone results usable directly in the date handling Laravel already ships with. - A small surface: cityExists, getAllCities and getAllData, which is the whole thing. - Page: https://ilhamriski.com/laravel-timezone-by-city/ - Category: Open Source · PHP - Tech: PHP, Laravel - Tags: PHP, Laravel, Package - Source: https://github.com/IlhamriSKY/laravel-timezone-by-city - GitHub stars: 15 - Latest version: v1.0.0 - Screenshot: https://ilhamriski.com/works/laravel-timezone.jpg ### VS Code: S.ID Shortlink A Visual Studio Code extension to create and manage S.ID short links without leaving the editor. Published on the VS Code Marketplace. A Visual Studio Code extension for S.ID short links, so a link can be created without leaving the editor and pasted straight into what you are writing. It authenticates against the S.ID API with an X-Auth-Id and X-Auth-Key pair, set once through the command palette. After that, generating a short URL is a command, and the links you have already made are listed in the editor with pagination, ready to copy or edit. It is MIT licensed and published on the Visual Studio Marketplace. What it does: - Shorten from the palette: One command, paste the long URL, get the short one back where you are working. - Manage what you made: The existing links listed in the editor with pagination, to copy or edit. - Credentials set once: X-Auth-Id and X-Auth-Key entered through the command palette and reused. - Page: https://ilhamriski.com/sid-shortlink/ - Category: Extension · JavaScript - Tech: JavaScript, Node.js, Axios, VS Code - Tags: VS Code, JavaScript, Extension - Source: https://github.com/IlhamriSKY/Sid-URL-Shortener - GitHub stars: 14 - Latest version: v0.0.2 - Screenshot: https://ilhamriski.com/works/sid-shortlink.png ### VS Code: Doa Harian Katolik A VS Code extension providing a daily Catholic liturgical calendar, prayer collections, saint information, Bible access, and daily prayer reminders. A Visual Studio Code extension that keeps the daily Catholic liturgy at hand while you work. It carries the daily liturgical calendar, a searchable collection of prayers, the saint commemorated each day, and Bible access searchable by book and verse. On top of that it can remind you to pray: the Angelus in the morning and at noon, and the Divine Mercy chaplet, each fired against the timezone of the city you set rather than the machine's locale. The liturgical calendar data comes from imankatolik.or.id. It is MIT licensed and published on the Visual Studio Marketplace. What it does: - Daily liturgical calendar: The day's liturgy, followed through the year. - Searchable prayers: A prayer collection with search, so a specific one is a few keystrokes away. - Saint of the day: Who is commemorated, alongside the day's calendar entry. - Reminders on your city's time: Angelus morning and noon and the Divine Mercy chaplet, timed to the city you choose. - Page: https://ilhamriski.com/doa-harian-katolik/ - Category: Extension · JavaScript - Tech: JavaScript, Node.js, Axios, VS Code - Tags: VS Code, JavaScript, Extension - Source: https://github.com/IlhamriSKY/Doa-Harian-Katolik - GitHub stars: 10 - Latest version: v0.0.2 - Screenshot: https://ilhamriski.com/works/doa-harian.png ### Bian Real Estate A property portal built on Laravel: searchable listings, detailed property pages with galleries and agents, property articles, and an enquiry flow, listing to enquiry in one place. Bian is a property portal built on Laravel: it takes a visitor from a search all the way to an enquiry without leaving the site. Listings are searchable and filterable, and each property gets a full page with its gallery, its details and the agent responsible for it. A property article section sits alongside the listings for market and buying-guide content, and the enquiry flow connects an interested visitor to the right agent. The article side is a small publishing system in its own right: pieces are written, published, and shown with related posts beside them, which is what gives a visitor arriving from search a reason to stay and a route into the listings. The contact page pairs the enquiry form with the questions buyers actually ask, so the form is not the only thing on it. What it does: - Searchable listings: Browse and filter the catalogue down to the properties that actually match. - Full property pages: Gallery, specifications and the assigned agent on a single page per property. - Articles: A content section for market notes and buying guides next to the listings. - Enquiry flow: One path from a property page to a recorded enquiry with the agent attached. - A front page that funnels: The landing page runs section by section into the listings instead of stopping at a hero image. - Page: https://ilhamriski.com/bian/ - Category: Web App · Laravel - Tech: Laravel, PHP, PostgreSQL - Tags: Laravel, Real Estate, Web App - Type: Client project - Screenshot: https://ilhamriski.com/reels/bian-cover-w.png ### I-ANDI · Neuropsych Norms A neuropsychological assessment platform: ten adapted tests, a normative database, and score analysis across education, age and region, with dashboards and the research behind it. I-ANDI (Indonesian Advanced Neuropsychological Diagnostics Infrastructure) is an assessment platform for psychologists, neuropsychologists, psychiatrists and practitioners who need scores read against norms that actually represent Indonesia. The workflow is deliberately simple: the practitioner administers the test themselves, registers with I-ANDI, then uploads the raw scores. The platform compares them against an up-to-date normative database and returns the analysis, broken down across education, age and region rather than against a single national average. Ten adapted tests are in the system, and it is built so more can be added rather than replaced. It handles both a full test battery and a single test, and the normative database is the point of the project: an integrated store covering the country's regions, with the research behind it and dashboards over the top. Behind the login, the dashboard is where the work lands: a client's scores placed against the norm group they actually belong to, plotted rather than tabulated, with the practitioner's own profile and settings alongside. The public half of the site carries the published research the battery rests on, so a clinician can read the paper before trusting the number. What it does: - An integrated normative database: Norms held centrally and kept current, covering regions across Indonesia instead of one national figure. - Ten adapted tests, extensible: The starting battery, with the flexibility to add further psychological tests to the platform. - Analysis by education, age and region: Scores read against the group a client actually belongs to, not an average of everyone. - Batteries and single tests: The same upload and scoring path whether it is a full battery or one instrument. - The research, on the site: The papers the battery rests on are published here and readable in full, not cited from somewhere else. - Built for the practitioner: Psychologists, neuropsychologists and psychiatrists register, administer the test themselves, then upload the raw scores. - Page: https://ilhamriski.com/norm/ - Category: Web App · Laravel - Tech: Laravel, PHP, PostgreSQL, Bootstrap, jQuery - Tags: Laravel, HealthTech, Data - Type: Client project - Live: https://norm.indonesian-andi.id - Screenshot: https://ilhamriski.com/reels/norm-cover-w.png ### Hospital Management System A hospital site that books its own appointments: a landing page funnelling to one action, a department directory, and a booking flow that respects real opening hours. A hospital site whose job is to book its own appointments rather than hand the visitor a phone number. The landing page funnels towards one action, a directory covers the departments and what each one treats, and the booking flow respects real opening hours, so a visitor cannot request a slot the hospital was never open for. The department directory is the middle step most hospital sites skip. A visitor who knows they need cardiology and not neurology picks the right clinic first, and the booking form then only offers the doctors who belong to it. Booking asks whether the patient is new or returning, because those are genuinely two different paths, and then puts a doctor and a date against the hours that department is actually open, so the front desk never receives a request it could not have honoured. What it does: - One clear action: A landing page built around booking rather than around a brochure. - Department directory: Departments and services listed so a visitor can find the right one before booking. - Booking against real hours: Available slots follow the hospital's actual opening times, per department. - A doctor, not just a department: The booking picks the clinician as well as the date, from the ones attached to that department. - New and returning patients: Two paths through the same form, because a first visit asks for details a returning one already has on file. - Page: https://ilhamriski.com/hospital/ - Category: Web App · Laravel - Tech: Laravel, PHP, PostgreSQL - Tags: Laravel, Healthcare, Booking - Type: Client project - Screenshot: https://ilhamriski.com/reels/hospital-cover-w.png ### Integrated Obesity Clinic A bilingual clinic site for the Integrated Obesity Clinic at RSCM: a service catalogue, doctor profiles, and a health blog, built end to end. A bilingual site for the Integrated Obesity Clinic at RSCM, built end to end: information architecture, design and implementation. It carries a catalogue of the clinic's services, profiles for the doctors who deliver them, and a health blog for patient education. Every piece of it exists in both Indonesian and English, so the same content serves local patients and international ones from one system. Bilingual here means one system, not two sites. Each service, doctor profile and article is written and edited in both languages behind the same admin, which is what stops the second language quietly falling a year behind the first. The clinic publishes all of it itself; the site does not need me in order to stay current. What it does: - Bilingual throughout: Indonesian and English maintained side by side rather than as two separate sites. - Service catalogue: The clinic's treatments and programmes, described for patients. - Doctor profiles: The team behind the clinic, linked to the services they run. - Health blog: Patient education content published by the clinic itself. - Edited by the clinic: Services, doctors and articles are all editable in place, in both languages, without a developer. - Page: https://ilhamriski.com/endokrin/ - Category: Web App · WordPress - Tech: WordPress, PHP, MySQL - Tags: WordPress, Healthcare, Bilingual - Type: Client project - Live: https://endokrinjakarta.com - Screenshot: https://ilhamriski.com/reels/endokrin-cover-w.png ### Suzuki Karunia Jaya Marine An online catalogue for a Suzuki marine dealer: outboard motors with stock, pricing and full specifications, each engine on its own page. An online catalogue for Suzuki Karunia Jaya Marine, an authorised Suzuki marine dealer. Every outboard motor gets its own page with the full specification, current pricing and stock, so a buyer can compare engines properly before contacting the dealer instead of asking for a price list. A dealer catalogue lives or dies on whether the buyer can finish the comparison alone. The catalogue filters down to the horsepower band someone is actually shopping in, each engine opens on a page carrying its full specification and features, and the price and availability sit on that page rather than in a PDF that has to be requested. The About page names the dealer and the Suzuki lines they are authorised for, and the enquiry form goes to the sales desk with the engine already in mind. What it does: - A page per engine: Each outboard listed on its own page with its complete specification. - Stock and pricing: Availability and price shown with the engine rather than kept in a separate document. - Built to compare: A catalogue structured so two engines can be weighed against each other. - Filtered by engine size: The catalogue narrows to the horsepower band the buyer is actually shopping in. - Straight to sales: The enquiry lands with the dealer's sales desk rather than in a general inbox. - Page: https://ilhamriski.com/karunia/ - Category: Web App · WordPress - Tech: WordPress, PHP, MySQL - Tags: WordPress, Catalogue, Marine - Type: Client project - Live: https://suzukikaruniajayamarine.com - Screenshot: https://ilhamriski.com/reels/karunia-cover-w.png ### Gereja Santo Yusuf Gedangan A parish site for the first Catholic church in Semarang: a welcoming home page, parish news and mass schedules, a short history, and a gallery of parish life. A parish site for Gereja Santo Yusuf Gedangan, the oldest Catholic church in Semarang. It is built for two audiences at once: the parishioner who wants this week's mass schedule and the parish news, and the visitor who has come for the history of the building. A gallery of parish life sits behind both, and the home page is written to welcome rather than to announce. The parish needed the site to do two things and had no appetite for a third: publish this week's schedule without anyone having to phone, and keep the photographs somewhere that is not a volunteer's handset. Both are edited by the secretariat itself. The rest of the front page (the welcome, the verse, the short history) is written once and left alone, which is the right amount of upkeep to hand to whoever inherits it. What it does: - Mass schedules: The schedule kept current and easy to find, because it is what most visits are for. - Parish news: Announcements and parish activity published by the parish. - A short history: The story of the first Catholic church in Semarang, for visitors. - Gallery: Photographs of parish life across the year, grouped by the event they came from. - Kept by the secretariat: Schedules, news and photographs are edited in place by the parish rather than sent to a developer. - Page: https://ilhamriski.com/gereja/ - Category: Web App · WordPress - Tech: WordPress, PHP, MySQL - Tags: WordPress, Web Design, Community - Type: Client project - Live: https://gerejagedangan.id - Screenshot: https://ilhamriski.com/reels/gereja-cover-w.png ### HANI · Halo Alumni A social platform built for Soegijapranata Catholic University alumni: stay connected, share job vacancies, and access campus updates, networking tailored to alumni. HANI (Halo Alumni) is the alumni network for Soegijapranata Catholic University, delivered during my time working on the university's web systems. It gives graduates one place to stay in touch with each other and with campus: a profile and directory to find people again, job vacancies shared within the alumni community, and campus updates that would otherwise never reach them. It is a native PHP and PostgreSQL build, and it runs on the university's own domain. It is more than a directory. There is a timeline alumni post to and comment on, a forum behind it for the longer threads, a job board where openings are passed alumni to alumni instead of through a public site, and campus events to browse, join, and look back on afterwards. Profiles carry the things graduates recognise each other by (major, cohort and graduation year), so searching for someone works the way people actually remember them. Two pieces make it useful after graduation rather than merely nostalgic. Every graduate is issued a digital alumni card with its own verification QR, and a document desk handles the requests the university would otherwise field by email: diploma, transcript and accreditation letters, downloaded from the account. An e-library keeps journal access open long after the student login has expired. What it does: - Alumni directory: Graduates findable again, by cohort and by programme. - Job vacancies: Openings shared alumni to alumni rather than through a public board. - Campus updates: University news delivered to the people who have already left it. - A feed and a forum: A timeline alumni post and comment on, with threaded discussion behind it for the longer conversations. - Events: Campus events alumni can browse, join, and look back on once they are over. - A digital alumni card: Issued per graduate, carrying its own verification QR. - Documents on demand: Diploma, transcript and accreditation letters requested and downloaded from the account. - E-library: Journal access that stays open long after the student login has expired. - Page: https://ilhamriski.com/hani/ - Category: Web App · PHP - Tech: PHP, PostgreSQL - Tags: PHP, PostgreSQL, Social Platform - Type: Client project - Live: https://hani.unika.ac.id - Screenshot: https://ilhamriski.com/reels/hani-cover-w.png ### Tracer Study Support Data The university's Kemdikbud tracer study, end to end: alumni fill in the survey, thirty thousand graduate records stay searchable behind it, and ten live charts turn the responses into the employment and coverage picture the ministry asks for. A web application for the Kemdikbud tracer study, the national survey that asks graduates what happened after they left. Its real job is completeness. It collects alumni responses, tracks who has answered and who has not, and reports the result in the shape the ministry expects, so the university can see its coverage while the survey is still open rather than after the deadline has passed. The dashboard is where it earns that. Ten live charts cover the employment rate, how long graduates waited before their first job, and how well that job matched what they studied, alongside employer ratings across every competency the questionnaire asks about. Around thirty thousand alumni records sit behind the charts, searchable and filterable, so a figure can always be opened back up into the people it came from. Administration is part of the same application rather than a second system: accounts, roles and permissions for each kind of user, and the master data (job positions mapped to faculties) that every report is grouped by. What it does: - Structured collection: Alumni responses captured against the tracer-study questionnaire. - Coverage tracking: Who has responded and who is still outstanding, visible while it still matters. - Reporting: Output shaped for the ministry's reporting rather than exported and reformatted by hand. - Ten live charts: Employment rate, the wait before a first job, and how well that job matched the degree. - Employer ratings: Competency-by-competency scores collected from the employers themselves, not only from graduates. - Thirty thousand records: Every alumnus searchable and filterable behind the charts, so a number can be opened up. - Roles and master data: Accounts, per-role permissions, and the faculty-to-position mapping the reports group by. - Page: https://ilhamriski.com/tracer-study/ - Category: Web App · Laravel - Tech: Laravel, PHP, PostgreSQL - Tags: Laravel, Data, Dashboard - Type: Client project - Live: https://tracer.unika.ac.id - Screenshot: https://ilhamriski.com/reels/tracer-cover-w.png ### Bio Link, Short Link & QR Shorten URLs, build bio-link pages, and generate custom QR codes, vCard links, and file links, a link toolkit made for the SCU community. A link toolkit built for the Soegijapranata Catholic University community, covering the four things people kept asking separate tools for. It shortens URLs, builds bio-link pages for a profile's worth of links, generates custom QR codes, and produces vCard and file links. Keeping them in one system means one account, one dashboard, and links that stay under a domain the university controls instead of scattered across free services. The bio-link builder is the largest part of it: a page is assembled from blocks, restyled with themes and the community's own branding, and previewed on a phone frame while it is being edited. Each page gets its own short URL and shows its live click count beside it. The QR generator goes past plain URLs to WiFi credentials, vCards, crypto addresses and PayPal links, styled to match the page they belong to, and the statistics screen charts clicks over time by link and by source. What it does: - Short links: URL shortening with custom slugs under one domain. - Bio-link pages: A single page collecting the links a profile needs to hand out. - QR codes: Custom codes generated for any of the links created. - vCard and file links: Contact cards and file downloads shared as links like anything else. - A block editor with live preview: Pages built from blocks and themes, previewed on a phone frame as they are edited. - Click statistics: Clicks over time, per link and per source, sitting next to the link that earned them. - Page: https://ilhamriski.com/biourl/ - Category: Web App · PHP - Tech: PHP, Tailwind CSS, PostgreSQL - Tags: PHP, Web App, QR Code - Type: Client project - Live: https://biourl.me - Screenshot: https://ilhamriski.com/reels/biourl-cover-w.png ### Workout App · UI Concept A clean UI/UX concept design for a workout & exercise mobile app: layouts, activity tracking screens, and a calm, focused visual system. A UI and UX concept for a workout and exercise app, done as a design exercise rather than a build. The brief I set myself was calm: a training app is used mid-set, out of breath, so the screens had to survive a two-second glance. That drove a restrained palette, generous type, and activity tracking that reads at arm's length. The set covers the layouts, the tracking screens and the visual system that ties them together, drawn in Figma with Photoshop for the imagery. What it does: - A calm visual system: Restrained colour and generous type, so a screen reads at a glance mid-workout. - Activity tracking screens: The progress and session views, designed as the app's centre of gravity. - A full layout set: Enough screens to show the system holding together, not a single hero shot. - Page: https://ilhamriski.com/workout-ui/ - Category: Design · UI/UX - Tech: Figma, Adobe Photoshop - Tags: UI/UX, Figma, Mobile - Type: Design - Screenshot: https://ilhamriski.com/works/workout-ui.jpg ## Skills - Languages: PHP, Python, JavaScript, TypeScript, Rust - Frameworks: Laravel, Vue.js, React, Node.js, Astro - Data: MySQL, MariaDB, Redis, REST APIs, PostgreSQL - Tooling: Nginx, GitHub, Linux, TEDI, VS Code ### Language distribution across public repositories - JavaScript: 40% - PHP: 23% - Python: 20% - TypeScript: 11% - CSS: 6% ## Clients and organisations he has built for - Soegijapranata Catholic University - Suzuki Marine - Graha Transport - Meditest - Triple Seven Vision - Triple Seven Interiors - Decodes Media - Tarink Visual - Maria Ciherang Sukatani ## Blog ### Loop Engineering: /goal, /loop, and work that continues without you Published 2026-08-28 · 5 min read · Tags: Claude Code, Automation, Loop Engineering, Advanced Read at https://ilhamriski.com/blog/loop-engineering-goal-loop-unattended-work/ > The last step is not a better prompt, it is a system: something that discovers work, isolates it, makes a change, checks it, and leaves a trail you can audit. Two commands supply the missing halves, a finish line the agent cannot walk past and a heartbeat that brings it back, and a plain file supplies the memory that context resets keep destroying. Everything so far has been a piece: a check the agent can run, rules the repo carries, hooks that enforce them, isolation so several agents can work at once, and a reviewer that did not write the code. Assembled, they stop being tips and start being a system. Addy Osmani calls this [loop engineering](https://addyosmani.com/blog/loop-engineering/), and the phrase is right: the leverage moves from the quality of any single prompt to the shape of the loop around it. What follows is that idea with the two Claude Code commands that make it concrete, plus the piece that is easiest to skip and hurts most when you do. #### The finish line: /goal The most common failure in a long session is not a wrong answer, it is stopping too early. “I have made good progress, let me know if you would like me to continue” is not a bug in the model, it is what happens when nothing in the session defines done. `/goal` takes a plain English condition and installs it as a stop gate for the session. Every time the agent tries to end its turn, that attempt is checked against the condition. If the work does not satisfy it, stopping is refused and the agent is told why, so it goes back. When the condition holds, the goal clears itself. **what /goal actually changes** 1. Work happens: Claude finishes a piece of it. 2. It tries to stop: Every attempt to end the turn passes through the gate. 3. The condition is checked: Met? The goal clears itself and the turn ends. 4. Not met: Stopping is refused, it is told why, and it goes back to work. Then back to the start. *The gate is on the exit, not on the work.* If you read the previous post, you already know what this is: a Stop hook with a condition attached. That is worth knowing, because it tells you exactly what kind of condition works. A gate can only be as good as what it can check. ```bash /goal every test in packages/api passes and pnpm lint reports zero errors /goal the /pricing page renders the three new tiers, has no console errors at 375px and 1440px, and tsc is clean ``` - **Prefer an exit code.** “`pnpm test` exits zero” beats “the tests look fine”, for the same reason the first post in this series exists. - **One outcome, not six.** An omnibus goal becomes a long session where the first item was done an hour ago and quietly forgotten. Sequence small goals instead. - **Make satisfying it leave evidence.** Output, a diff, a measurement. Something you can read afterwards. - **Know it can be unreachable.** A condition depending on a flaky external service will never clear. `/goal clear` drops it; you will not need that on success, because a met goal clears itself. #### The heartbeat: /loop A goal says what done means. It says nothing about when to look again, and a lot of real work is exactly that: a deploy that takes nine minutes, a CI matrix, a queue draining, a nightly job you want to catch. `/loop` re-runs a prompt or a slash command on a schedule. Give it an interval and it uses that. Leave the interval out and the agent paces itself, choosing each delay and having to state a reason for it. **two ways to pace a loop** /loop 5m /check-deploy - A fixed heartbeat: every five minutes, regardless. - Right for something that changes on a schedule of its own. - A five minute poll on an eight minute job spends most of its wakeups learning that it is still running. /loop, no interval - The agent picks each delay, and has to say why. - Right when the correct wait depends on what the last check found. - A build it just watched start earns an eight minute wait on purpose. *Self pacing wins whenever the right wait is not knowable in advance.* The self paced form is the more interesting one, because a fixed interval encodes an assumption about the world that is usually wrong. An agent that just watched a build start can decide to wait eight minutes on purpose, and being made to justify the number keeps the pacing honest. **what an interval actually costs** | Interval | Wakeups in 8h | What it is for | | --- | --- | --- | | 1m | 480 | almost nothing. this is a bug | | 5m | 96 | a deploy you are watching right now | | 20m | 24 | a long CI matrix, a slow queue | | 1h | 8 | a background sweep | *Every wakeup is a turn, and a turn is not free. Long intervals are usually correct, not a compromise.* The failure to avoid: do not poll something that would have told you anyway. If a background command already wakes the session when it exits, a one minute loop layered on top is pure waste. Loops are for state the session has no other way to hear about. #### State, because context is not memory This is the piece that separates a loop that runs overnight from one that runs for twenty minutes and then starts repeating itself. A session’s context does not survive forever. It gets compacted, cleared, or simply ends. Anything the loop needs to know next time has to be written down somewhere that is not the conversation. A plain markdown file is enough, and being plain is a feature: you can read it, edit it, and commit it. ```markdown # loop state, updated at the end of every pass ## done - 2026-08-27 flaky `order.spec.ts`: retried timer, PR #412, merged ## in progress - 2026-08-28 `checkout/total.ts` rounding: worktree `wt/fix-rounding` tests still red: 2 of 14. Do not open a PR yet. ## blocked, needs a human - staging DB credentials expired; the integration suite cannot run ``` Tell the loop to read it first and write it last, and the whole thing becomes resumable. It also becomes auditable, which matters more than it sounds: when you come back in the morning, the file is the log of what a machine decided while you were asleep. #### One loop, assembled **the whole thing, in one picture** 1. State: A file on disk, read first. It survives every context reset, which the conversation does not. 2. Discover: What broke since yesterday? CI, issues, the commits that landed. 3. Isolate: One worktree per item, so nothing collides. 4. Make: The maker agent changes it. 5. Check: The checker, plus the commands from the first post in this series. 6. Land: A pull request, for a human. Then the state file is written back. Then back to the start. *Every box is a post in this series. The edge back to the top is what makes it a loop instead of a script.* Read it top to bottom, then round again: state tells it where things stood, discovery finds what is broken, each item gets its own worktree, a maker changes it, a checker and the project’s own commands decide whether it is real, and what survives becomes a pull request with a human at the end of it. Then the state file is written back, so the next pass can be picked up by a session that remembers none of this. #### Guardrails, and they are not optional now 1. **Branch or worktree. Never your main checkout.** Unattended work on the tree you are reading is a bad trade for any amount of convenience. 2. **Machine checkable conditions only.** A goal that can only be judged by reading prose will be judged generously, at three in the morning, by something that wants to finish. 3. **Keep the permission leash on.** Unattended is the worst possible moment to turn prompts off. If a run genuinely needs none, put it somewhere disposable. 4. **Cap the spend before you start.** Turns are not free, and a tight interval is the most common way to discover that. 5. **A human lands it.** The loop can open the pull request. It should not be the thing that merges it. #### What the loop still will not do Two costs are worth naming, because the tooling will not surface them for you. The first is that **verification stays yours**. The checks make the loop possible, but a green suite means the tests passed, not that the change was right, and it is your name on the deploy. Automating the work does not automate the accountability. The second is quieter and compounds: **comprehension debt**. Code you did not write and did not read still becomes code you own. Shipping faster than you understand feels like leverage for a few weeks and then feels like maintaining a codebase somebody else left you. The loop is worth building precisely so you spend your attention on the parts that need it. That only works if you actually spend it there. Build the loop. Keep reading the diffs. --- ### Parallel Without Chaos: worktrees and the maker/checker split Published 2026-08-24 · 3 min read · Tags: Claude Code, Git, Subagents, Parallel Read at https://ilhamriski.com/blog/parallel-agents-worktrees-maker-checker/ > Running several agents at once fails for a boring reason: they share one working directory and overwrite each other. Git worktrees fix that with a feature that has existed since 2015, and once work is isolated, the second habit worth adopting is never letting the agent that wrote something be the one that approves it. The first time you run two agents on the same repository at once it looks like free throughput. It is not, and the reason is not subtle. **three agents, one checkout** 1. Agent A: Edits src/api/user.ts. 2. Agent B: Edits src/api/user.ts, and lands on top of A. 3. Agent C: Runs the test suite over a tree that is now half of each. *Nobody is wrong, and the result is garbage.* There is no clever prompting fix for this. Two processes editing the same file will always be two processes editing the same file. The fix is filesystem level, and Git has shipped it since 2015. #### Worktrees: one repository, several checkouts A worktree is an additional working directory attached to the same repository, on its own branch. Same history, same remotes, separate files. Made for exactly this. ```bash # from the repo git worktree add ../wt/fix-auth -b fix-auth git worktree add ../wt/fix-rounding -b fix-rounding # ... run an agent in each directory ... git worktree list git worktree remove ../wt/fix-auth ``` **one repo, three working trees** | Agent | Working tree | Branch | | --- | --- | --- | | A | `~/wt/fix-auth` | fix-auth | | B | `~/wt/fix-rounding` | fix-rounding | | C | `~/wt/upgrade-vite` | upgrade-vite | *One .git, three checkouts, no collisions. Merging is a pull request, which you already know how to do.* Claude Code can create and work inside an isolated worktree for you, and a subagent can be given one so its edits never touch the tree you are reading. The mechanic is worth understanding either way, because when something goes wrong you will be debugging Git, not the agent. Two practical notes. Give a fresh worktree whatever it needs to actually run: dependencies, an env file, a database URL. A worktree that cannot run the checks is a worktree that cannot close its own loop, which puts you right back to reviewing prose. And clean them up, because a stale worktree is a checkout of an old branch that will confuse the next search you run. #### Where parallel stops paying The ceiling is not the tooling. Reports from teams running this in practice put the sustainable number somewhere around four to eight concurrent working trees per person, and the limit is not compute or context, it is that **you** become the bottleneck. Eight agents can produce eight branches an hour. You cannot review eight branches an hour, and unreviewed branches are not progress, they are inventory. - **Parallelise independent work.** Three unrelated bugs in three areas: ideal. - **Do not parallelise a sequence.** If B needs A’s refactor, running them together produces two conflicting refactors. - **Count your own capacity first.** Start two. Add a third when two stopped feeling like enough, not before. #### Keep the maker away from the checker The second half of parallel work is not about speed, it is about honesty. An agent that has just spent forty turns building something is the worst possible reviewer of it: it knows what it meant, it remembers why each decision seemed reasonable, and it very much wants to be finished. **two agents, deliberately different jobs** The maker - Wrote the change. - Knows why it did that. - Wants to be finished. The checker - Never saw it being written. - Has the diff and the rules, and nothing else. - Is there to find the problem. *An agent reviewing its own work grades its intent. A second one, with no history, grades the artefact.* So give the review to a separate agent with a clean context, the diff, and the standards it should hold the diff to. It has no attachment to the approach and no memory of the dead ends, so it reads the change the way a colleague would: as an artefact, not as the end of a story. `.claude/agents/reviewer.md` ```markdown --- name: reviewer description: Reviews a diff against the project checks and conventions. Use before anything is merged. Reports findings, never edits. tools: Read, Glob, Grep, Bash --- You are reviewing a change you did not write. You have the diff and the repository, and you do not have the reasoning behind it. Report only what you can point at with a `file:line`. For each finding, give the input that makes it fail. If the checks in CLAUDE.md were not run, say so first and stop. ``` The `tools` line is doing real work there. A reviewer that cannot write cannot “helpfully” fix what it found, which keeps the review a review. Restricting a capability in configuration is a stronger guarantee than asking for restraint in a prompt, and it is the same instinct as the hook in the previous post. This costs tokens, sometimes a lot of them. It buys the one thing an agent cannot give you on its own, which is a second opinion that has not already agreed with itself. --- ### Conventions Are Advice. Hooks Are Guarantees. Published 2026-08-20 · 3 min read · Tags: Claude Code, Hooks, Automation, Workflow Read at https://ilhamriski.com/blog/claude-code-hooks-conventions-into-guarantees/ > A rule in a markdown file is followed most of the time, which is fine for a colleague and useless for a build step. Hooks are your own code running on the agent’s tool calls, which turns a convention into something that happens whether or not anyone remembered it. Write “always run the formatter after editing” in a project file and it will happen most of the time. Most of the time is a wonderful property for a colleague and a terrible one for a build step, and the gap shows up as review comments about whitespace, which is the least interesting way to spend a review. Hooks close that gap by moving the rule out of the conversation entirely. **the same rule, two places to put it** | | Advice | Guarantee | | --- | --- | --- | | what it is | “always format after an edit” | a PostToolUse hook | | lives in | `CLAUDE.md` | `settings.json` | | runs when | remembered | every time | | costs | tokens, every turn | nothing | | fails | silently | loudly | *If a rule matters more than it costs to enforce, it should not be a sentence.* #### Where a hook gets a say A hook is a shell command Claude Code runs at a fixed point in its own lifecycle. Your script, your machine, and its output comes back to the agent as feedback, which is how a hook refuses something and explains itself in the same breath. **the tool call lifecycle** 1. You ask: Something you want done. 2. A tool is chosen: PreToolUse runs here. Your script sees the call first and can refuse it, with a reason. 3. The tool runs: Bash, Edit, Write, and the rest. PostToolUse runs after it: formatters, codegen, the lint you keep forgetting. 4. The result is read: Claude reads what came back and carries on. 5. It tries to finish: Stop runs here, and can send it back to work. *PreToolUse can block. PostToolUse reacts. Stop can refuse the ending.* They are configured in `settings.json` and matched against the tool being called, so a hook can apply to every edit, or only to Bash, or only to a subset you match with a pattern. ```json { "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": ".claude/hooks/format-changed.sh" } ] } ] } } ``` The hook receives the tool call as JSON on standard input, so the script knows exactly which file was written and can format only that one instead of the whole tree. Keep the script in the repo next to the config; a hook that shells out to a one liner is a hook nobody can debug in six months. #### Three hooks worth having 1. **Format on write.** The classic, and the one that deletes a whole category of review comment. PostToolUse on `Edit|Write`. 2. **Refuse a command class.** A PreToolUse hook on Bash that reads the command and exits non-zero for the ones you never want run unattended. This is a real guarantee, unlike a line in CLAUDE.md asking politely. 3. **Rewrite a command into the one you meant.** If every `git` call in your project should really go through a wrapper, a hook can do that substitution every single time, with no cooperation from the model and no tokens spent explaining it. That third one is worth dwelling on, because it is the pattern people miss. Anything you find yourself correcting in the transcript, over and over, is a candidate for silent substitution. You are not teaching the agent a preference; you are removing the choice. #### Which settings file There are three, and the difference is a team question rather than a technical one. - `~/.claude/settings.json` follows you into every project. Your habits live here. - `.claude/settings.json` is committed. Team conventions live here, and everyone gets them on clone. - `.claude/settings.local.json` is gitignored. Your personal permission allowlist for this repo lives here, because nobody else wants it. #### A hook is code, with no model in the loop This is the part to take seriously. A hook runs on every matching event, immediately, with no judgement applied. A PostToolUse hook with a bad path will happily corrupt every file the agent touches, quickly, and the agent will keep working on the wreckage because as far as it knows the write succeeded. - Run the command by hand before you wire it to an event. Every time. - Make it a no-op when it does not apply, rather than an error, or you will spend the session reading hook failures. - Keep it fast. It runs on every matching tool call, and a two second hook on a busy session is a tax you will feel. - Log what it did somewhere you can read afterwards. #### The Stop hook is the interesting one PreToolUse and PostToolUse are about individual actions. The Stop hook is about the ending: it runs when the agent tries to finish, and it can send it back to work. That is a different kind of power, because it is the only one that can enforce a definition of done rather than a definition of behaviour. It is also, not coincidentally, the mechanism behind the goal command in the last post of this series. Once you have seen the hook, that feature stops looking like magic and starts looking like a gate you could have built yourself. --- ### The Repo Is the Prompt: rules that earn their place Published 2026-08-17 · 3 min read · Tags: Claude Code, CLAUDE.md, Skills, Context Read at https://ilhamriski.com/blog/the-repo-is-the-prompt-claude-md-memory-skills/ > Every session that starts with you explaining the build command is a session paying rent on knowledge that should already be in the repo. CLAUDE.md, memory and skills each hold a different kind of rule, and the reason a written rule still gets ignored is almost always that it is written in the wrong one. Once checks are in place, the next thing that limits an agent is not capability, it is that it starts every session knowing nothing about your project except what it can read. Some of what it needs is not readable. The build script that works, the directory that is generated, the fact that the config file lies. There are three places to put that knowledge, they cost different amounts, and picking wrong is the usual reason a rule you wrote down gets ignored anyway. **three layers, three costs** 1. Always read: ./CLAUDE.md and ~/.claude/CLAUDE.md. Read on every single turn, so every line is rent. Keep it under a screen. 2. Recalled when it matches: Memory. One fact per note, pulled in only when it is relevant to the task at hand. 3. Opened on demand: .claude/skills/…. A whole procedure, read only when the task matches its description. *The higher a rule sits, the more often it is read, and the more it costs to keep there.* #### CLAUDE.md is expensive, so make every line fight for its place A `CLAUDE.md` in the repo root is read at the start of every session. That makes it the right place for rules with no exceptions and the wrong place for everything else, because you pay for all of it on every turn, including the turns where none of it applies. The test for a line is simple and most files fail it: **would the agent get this wrong without being told?** A list of your dependencies fails, because it can read `package.json`. A description of your folder structure fails, because it can look. What passes is the surprising part. ```markdown ## Build and deploy - The app lives in `.app/`; the repo root is the served docroot. - `npm run build` writes `.app/dist/`. It does NOT publish. - `node .app/deploy.js` mirrors `dist/` into the docroot. That is the deploy. - `api/*.php` is not part of the build. Editing it is live immediately. ## Traps - Do not run `prettier --write`. The config exists but the repo was never formatted with it, so it rewrites every file it touches. - `#about` must not get `overflow-hidden`; it breaks the sticky photo card. ``` Every line there is something that has actually gone wrong. That is what a good file looks like: not documentation, a list of scars. It is also short, which is the other half of the job, because a long file gets skimmed by humans and diluted by everything around it. #### Memory is for what the repository cannot say Some things are true, load bearing, and unwritable in a repo file. Why the ugly implementation was the right call. That the staging database is a March restore and its numbers mean nothing. That the client already rejected the obvious design. None of it belongs in a file every contributor reads, and all of it changes the advice you get. The discipline that makes memory useful is one fact per note, with the reason attached. “We do not use soft deletes” is half a note. “We do not use soft deletes, because the reporting views join on deleted rows and silently double count” is a whole one, and it is the half you left out that stops the next agent from helpfully re-enabling them. #### Skills are procedures, and the description is the index The third layer is the one people underuse. A skill is a folder with a markdown file, read only when the task matches its description. That makes it right for anything long and situational: a deploy runbook, a review checklist, the exact sequence for regenerating a client. `.claude/skills/deploy/SKILL.md` ```markdown --- name: deploy description: Build and publish this site to the served docroot. Use when asked to deploy, publish, ship, release, or push the site live. --- 1. `npm run lint` and `npm run build` must both be clean first. 2. `node .app/deploy.js` mirrors `dist/` into the docroot. 3. Verify: the docroot `index.html` must match `dist/index.html`. 4. `api/*.php` is not deployed. It is already live. ``` The `description` is not a label, it is the entire retrieval mechanism, and it is the only part read before the skill is opened. Write it with the words that will actually be in the request, synonyms included. “Deployment helper” will sit unused forever; “deploy, publish, ship, release, push live” gets found. #### Which layer does this belong in? **which layer does this rule belong in?** 1. Would it get this wrong?: If the agent would not get it wrong without being told, delete the line. It is describing the code. 2. Is it always true here?: True for every task in this repo? Then CLAUDE.md. 3. Is it a decision?: A fact about you, or about a call already made? Then a memory note. 4. Does it have steps?: A procedure you would otherwise re-explain? Then a skill. Otherwise just say it in the prompt. *The first question deletes more lines than the other three add.* And the timing rule that matters more than the taxonomy: **do not write any of this in advance**. Write a rule the third time you repeat yourself. Rules written from imagination describe a project you think you have; rules written from repetition describe the one you do. --- ### Make the Agent Prove It: the check is the contract Published 2026-08-14 · 3 min read · Tags: Claude Code, Verification, Testing, Workflow Read at https://ilhamriski.com/blog/make-your-agent-prove-it-verification-loops/ > An agent will describe its work more convincingly than it did it. The upgrade that changes everything about working with one is not a better prompt, it is ending every task in something that returns an exit code, including the visual changes you thought you had to eyeball. There is a stage most people get stuck at. The agent works, you read what it says it did, it sounds right, you merge it. Then a week later you find the rounding is wrong in a currency you do not use, and you realise you have been reviewing prose, not code. The fix is not a better prompt. It is refusing to be the check. **the only structural change that matters** Described - The agent edits the file. - It reports: “I fixed the rounding and the tests should pass.” - You read the prose and decide whether to believe it. - **Your attention is the check.** Proven - The agent edits the file. - It runs the check itself. - Exit 0, it is done. Exit 1, it reads the failure and goes again. - **The check is the check.** *The same task, with and without something the agent can run itself.* An agent that can run a command can close its own loop: make a change, run the check, read the failure, try again. Without a check, it produces one attempt and a summary, and the loop closes through you. That is the whole difference, and it is why “write a test for this first” is often the fastest route to a fix, even when the fix is two lines. #### Give it a ladder, not one big check Full test suites are slow, so agents avoid them, so they stop being the contract. What works is a ladder: cheap checks on every turn, expensive ones at the moment they matter. **the verification ladder** | Cost | Check | Catches | | --- | --- | --- | | free | `tsc --noEmit`, `eslint .` | type drift, dead code, unused imports | | seconds | the build | broken imports, bad config | | a minute | unit and integration tests | wrong behaviour | | a few minutes | a headless browser that measures the DOM | the thing you actually shipped | *Run the cheap ones every turn. Run the last one before you believe a visual change.* Write the ladder down where the agent will see it, because a check it does not know about does not exist. This is a four line block in a project file, and it removes the entire class of “I have made the change, you may want to run the tests”. ```markdown ## Checks Run after every change, in this order. All must be clean. - `npx tsc --noEmit -p tsconfig.json` - `npm run lint` - `npm run build` Do not report a change as done until the build is clean. ``` #### The part people skip: verifying what you can see Type checks and unit tests cover logic. They say nothing about whether the page looks right, and “looks right” is where visual work quietly rots. The usual answer is a screenshot, which is better than nothing and worse than it appears: a screenshot proves the page rendered, not that it rendered correctly. A headless browser can be driven from the same session, and once it is, the question stops being “does this look right” and becomes “what is the computed value”. That is a question with an answer. **measuring a visual change** 1. The question: Is the card border still 1px? 2. The wrong way: Take a screenshot and squint at it. 0.375px of a 1px border is invisible to you, and to the model. 3. The right way: Drive the page and measure the box. 4. The numbers: getComputedStyle(card).borderTopWidth"1px", card.getBoundingClientRect().height234.375, wrapper.clientHeight234 5. The answer: The clip is there: a fractional box inside a whole-pixel clip. *A real bug from this site. Three numbers found what no screenshot could show.* That is not a hypothetical. On this site, the cards in a scrolling row looked like their bottom border was missing, and no screenshot ever settled it. Measuring did, in one pass: the card was `234.375px` tall because a 16 by 9 image over a fractional box does not land on a whole pixel, while the wrapper clipped at `234`. The bottom border was losing 0.375 of its height on every viewport width. The fix was two pixels of padding, and it was only findable because the check was a number. You do not need a testing framework for this. A browser with remote debugging on, and a handful of evaluated expressions, is enough to turn a visual review into a set of assertions. ```text Ask for this shape, not "check it looks right": Open /pricing at 1440 and at 390. For each width, report: - document.documentElement.scrollWidth - clientWidth (must be 0) - the computed border width and colour of .plan-card - the bounding height of every .plan-card (must match) Then hover the first card and report the border colour again. ``` #### Make the check the definition of done Once checks exist, the last step is to stop treating them as a suggestion. “Do X” invites a report; “Do X, and it is not finished until Y exits zero” invites a loop. Same work, entirely different ending. - Name the command, not the intent. “The build passes” beats “make sure nothing is broken”. - Ask for the evidence in the reply: the actual output, not a claim about it. - When a check is slow, say when to run it, otherwise it will be run once at the start and never again. - If you cannot name a check for a task, that is worth noticing. It usually means the task is not specified yet. That last point is the one that keeps paying. Every workflow in the rest of this series depends on a machine checkable definition of done, because that is the only thing that lets an agent work while you are not reading every line. --- ### A VPN on Your Own VPS: hidden on port 443, monitored per device, ads blocked at DNS Published 2026-08-13 · 11 min read · Tags: VPN, sing-box, Nginx, AdGuard Read at https://ilhamriski.com/blog/a-vpn-on-your-own-vps-you-can-monitor/ > A locked-down network only lets 80 and 443 out, and a rented VPN hands the exit node to somebody else. Here is how I built my own instead: a VLESS tunnel wearing an ordinary WebSocket upgrade on 443, one path per device so I can see who is connected, and AdGuard Home filtering ads for everything inside the tunnel. Two things bother me about a rented VPN. The exit node belongs to someone else, so every request I make lands in somebody else’s log file. And the ad blocking, where it exists at all, stops at the browser. I already pay for a VPS, so the tunnel may as well be mine: one I can watch, and one that filters ads for every app on the phone rather than just the browser tab. The awkward part is the network I use it from. Only TCP 80 and 443 get out. WireGuard is UDP on a port of its own choosing, so it dies at the first hop, which means the tunnel has to look exactly like ordinary web traffic. This is the shape of what I ended up with. ```text restricted network (only 80/443 get out) | | TLS :443 v +-------------------------------------------+ | nginx | | location = /ws- | | logs: time, real IP, duration, bytes | +-------------------------------------------+ | 127.0.0.1:10501 phone | 127.0.0.1:10502 laptop v +-------------------------------------------+ | sing-box VLESS, no TLS, localhost only | | route: DNS -> hijack | | clash api on 127.0.0.1:9090 | +-------------------------------------------+ | | | :5353 | everything else v v +------------------+ the internet | AdGuard Home | | blocklists | | upstream = DoT | +------------------+ collector -> nginx log + clash api -> MariaDB -> dashboard ``` #### What each piece is doing - `nginx` already owns 443 for my websites. It terminates TLS, and one exact-match location per device proxies the WebSocket upgrade to a local port. - `sing-box` speaks VLESS over that WebSocket. It listens on `127.0.0.1` only, with no TLS of its own, because nginx has already done that part. - `AdGuard Home` is the DNS for the tunnel and for nothing else: bound to `127.0.0.1:5353`, not a system resolver, not reachable from the network. - A small collector reads two sources that each see half the picture, and writes them into MariaDB for a dashboard. #### Why WebSocket on 443 and not WireGuard WireGuard is faster and far simpler to set up. It is also a UDP flow on a non-standard port with a fixed handshake, which is the easiest thing in the world for a captive network to drop. A VLESS stream inside a WebSocket upgrade, behind a real certificate on 443, is just an HTTPS connection: same port, same handshake, same certificate authority as the websites on the same host. Someone who opens the hostname in a browser gets a 301 to my main site, because the catch-all location is a plain redirect. The price is throughput. You are running TCP inside TCP, so a lossy link recovers twice and feels it. For browsing, chat, and everything a phone does all day, I have never noticed. For a big download, I have. #### Step 1 · sing-box, listening on localhost only One inbound per device. Each gets its own port, its own UUID, and its own WebSocket path, which is what makes per-device monitoring possible later. Note there is no `tls` block anywhere: nginx is the only thing holding a certificate. ```json { "log": { "level": "warn", "timestamp": true }, "dns": { "servers": [ { "type": "udp", "tag": "adguard", "server": "127.0.0.1", "server_port": 5353 } ], "final": "adguard", "strategy": "prefer_ipv4" }, "inbounds": [ { "type": "vless", "tag": "phone", "listen": "127.0.0.1", "listen_port": 10501, "users": [{ "uuid": "PASTE-A-UUID", "name": "phone" }], "transport": { "type": "ws", "path": "/ws-PASTE-A-RANDOM-HEX" } }, { "type": "vless", "tag": "laptop", "listen": "127.0.0.1", "listen_port": 10502, "users": [{ "uuid": "PASTE-ANOTHER-UUID", "name": "laptop" }], "transport": { "type": "ws", "path": "/ws-PASTE-ANOTHER-HEX" } } ], "outbounds": [{ "type": "direct", "tag": "direct" }], "route": { "rules": [ { "action": "sniff" }, { "protocol": "dns", "action": "hijack-dns" } ], "final": "direct" }, "experimental": { "clash_api": { "external_controller": "127.0.0.1:9090", "secret": "PASTE-A-LONG-RANDOM-STRING" }, "cache_file": { "enabled": true } } } ``` Generate the secrets, never invent them by hand: `uuidgen` for the users, `openssl rand -hex 16` for each path, `openssl rand -hex 24` for the Clash API. The `hijack-dns` route rule is the line that makes the ad blocking unavoidable later, so keep it even before AdGuard exists. This is written for sing-box 1.12 and newer, where DNS servers are typed objects and sniffing moved from the inbound into a route action. On 1.11 and older the same config has a different shape. #### Step 2 · nginx as the front door The upgrade map goes in the http context once. Everything else lives in the vhost, and each device gets its own exact-match location. An exact match beats every prefix match, so the catch-all redirect at the bottom never touches the tunnel. ```nginx # http context, once map $http_upgrade $connection_upgrade { default upgrade; '' close; } log_format vpnsess escape=json '{"t":"$time_iso8601","path":"$uri","ip":"$remote_addr",' '"dur":$request_time,"in":$request_length,"out":$bytes_sent}'; server { listen 443 ssl http2; server_name tunnel.example.com; ssl_certificate /etc/letsencrypt/live/tunnel.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/tunnel.example.com/privkey.pem; # one block per device (I generate these from a devices file) location = /ws-THE-PHONES-HEX { if ($http_upgrade !~* "^websocket$") { return 404; } access_log /var/log/vpn/sessions.log vpnsess; proxy_pass http://127.0.0.1:10501; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_buffering off; proxy_cache off; proxy_read_timeout 86400s; proxy_send_timeout 86400s; } # everyone else gets an ordinary looking redirect location / { return 301 https://example.com$request_uri; } } ``` The `if` line matters more than it looks. Without it, a plain GET to a guessed path returns something that confirms the path exists. With it, anything that is not a WebSocket upgrade gets a 404, the same answer as a path that was never there. The long read and send timeouts stop nginx from tearing down an idle tunnel every 60 seconds, and `proxy_buffering off` keeps latency down. On the client, any VLESS client will do (sing-box, v2rayN, Hiddify, NekoBox). It is four values plus the certificate. ```json { "type": "vless", "tag": "home", "server": "tunnel.example.com", "server_port": 443, "uuid": "THE-UUID-FOR-THIS-DEVICE", "tls": { "enabled": true, "server_name": "tunnel.example.com" }, "transport": { "type": "ws", "path": "/ws-THIS-DEVICES-HEX" } } ``` #### Step 3 · Do not let nginx log the secret This one cost me a while to notice. The path is the thing that authorises the tunnel, and at nginx’s default error level every dropped WebSocket writes a line like `recv() failed ... GET /ws-` into a plaintext file, together with the client’s real IP. Phones drop connections constantly, so within a day that file is both a browsing-time trail and a leak of the one value you did not want written down. ```nginx # server level: no general access log, and only real faults in the error log access_log off; error_log /var/log/nginx/tunnel-error.log crit; ``` At `crit` a broken certificate or a dead upstream is still recorded, which is all I ever needed the error log for. The per-device `access_log ... vpnsess` inside each location survives, because that one is deliberate and I control its format. #### Step 4 · Ads filtered at DNS, inside the tunnel only AdGuard Home is the ad blocker, but it is not the system resolver and it is not on the network. It binds to loopback, and the only thing that can reach it is sing-box, which hijacks port 53 for everything inside the tunnel. That detail is what makes it work on a phone: an app that hardcodes 8.8.8.8 to dodge your DNS settings still ends up at AdGuard. ```yaml # AdGuardHome.yaml, the parts that matter bind_host: 127.0.0.1 # admin UI, reach it over an SSH tunnel bind_port: 3000 dns: bind_hosts: - 127.0.0.1 # never 0.0.0.0, or you have an open resolver port: 5353 # unprivileged, so the service needs no capabilities upstream_dns: - tls://dns.quad9.net - tls://one.one.one.one bootstrap_dns: - 9.9.9.9 - 1.1.1.1 upstream_mode: load_balance querylog: enabled: false # see the note at the end statistics: enabled: true interval: 24h ``` Two settings carry most of the weight. Binding to `127.0.0.1` means you have not accidentally published an open resolver for the internet to amplify attacks through. Setting the upstream to DoT means the network I am sitting on sees an encrypted tunnel and nothing else, not even the names I am resolving inside it. For blocklists, the defaults plus AdGuard DNS filter and OISD get rid of nearly everything without breaking sites. Add lists slowly. Every aggressive list eventually blocks a payment page at the worst possible moment. #### Step 5 · Monitoring, assembled from two halves Neither side sees the whole picture, and I have come to think of that as a feature rather than a limitation. - **nginx sees the client.** Real IP, when the session opened, how long it lasted, bytes in and out, and which device (the path identifies it). It cannot see one byte inside the tunnel. - **The Clash API sees the destinations.** Hostname, inbound tag (so, again, which device), upload and download per connection. It never sees the client’s real IP, because as far as sing-box is concerned the client is nginx on localhost. The Clash API is already switched on in the config above. It is a plain HTTP endpoint, bearer token, no client needed: ```bash SECRET=$(cat /etc/sing-box/clash-secret) curl -s -H "Authorization: Bearer $SECRET" \ http://127.0.0.1:9090/connections | jq '.connections[] | { device: .metadata.inboundTag, host: .metadata.host, up: .upload, down: .download }' ``` A small PHP process under systemd loops every few seconds: sample the Clash API, ingest whatever is new in the session log, geolocate each IP once and cache it, and write a per-minute traffic sample so the charts have history. Three tables are enough. ```sql CREATE TABLE vpn_sessions ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, device VARCHAR(64) NOT NULL, ip VARCHAR(45) NOT NULL, country VARCHAR(64) NULL, city VARCHAR(64) NULL, started DATETIME NOT NULL, duration DECIMAL(10,3) NOT NULL, bytes_in BIGINT UNSIGNED NOT NULL, bytes_out BIGINT UNSIGNED NOT NULL, KEY device_started (device, started) ); CREATE TABLE vpn_visits ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, device VARCHAR(64) NOT NULL, host VARCHAR(255) NOT NULL, seen_at DATETIME NOT NULL, hits INT UNSIGNED NOT NULL DEFAULT 1, UNIQUE KEY device_host_seen (device, host, seen_at) ); CREATE TABLE traffic_history ( sampled_at DATETIME PRIMARY KEY, up BIGINT UNSIGNED NOT NULL, down BIGINT UNSIGNED NOT NULL ); ``` The collector reads the log by byte offset and stores that offset in a small state table, so a restart resumes where it stopped instead of replaying the file. Rotate the session log daily and the whole thing stays small forever. The dashboard on top of this is deliberately boring: who is online now, traffic today per device, the last sessions with a city next to each IP, and the destinations each device has been talking to. That is the entire reason for building it instead of renting it. #### Step 6 · The services should not be able to do anything The packaged sing-box unit grants `CAP_NET_ADMIN`, `CAP_NET_RAW`, `CAP_SYS_PTRACE` and `CAP_DAC_READ_SEARCH`, because the same binary can also run TUN, tproxy, and redirect inbounds. This deployment does none of that. A VLESS inbound bound to loopback behind nginx needs no capabilities at all, so take them away in a drop-in rather than editing the packaged unit. ```ini # /etc/systemd/system/sing-box.service.d/hardening.conf [Service] CapabilityBoundingSet= AmbientCapabilities= NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true PrivateDevices=true ProtectProc=invisible ProtectKernelTunables=true ProtectKernelModules=true ProtectKernelLogs=true ProtectControlGroups=true RestrictNamespaces=true RestrictRealtime=true RestrictSUIDSGID=true RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK LockPersonality=true SystemCallArchitectures=native SystemCallFilter=@system-service SystemCallFilter=~@privileged @resources UMask=0077 ``` AdGuard gets the same treatment with `ReadWritePaths` pointed at its work directory, which is the only place it needs to write. Port 5353 being unprivileged is exactly why it can run with nothing. Then run `systemd-analyze security sing-box.service` and keep going until the number stops embarrassing you. #### What this gives you, and what it does not - It gives you **the exit node**. The logs are yours, the retention is yours, and nobody is selling the browsing history of the household. - It gives you **ad blocking everywhere**, including inside apps that have never heard of your browser extension, because the filtering happens before the connection is made rather than after the page loads. - It does **not** make you anonymous. The VPS is rented in your name and pays with your card. It moves your traffic out of a network you do not trust into one you do, which is a different and much more achievable goal. - It does **not** beat serious traffic analysis. Looking like HTTPS on 443 is enough for an ordinary filtered network, not for an adversary who measures timing and volume. One last thing, and it is the part I would think hardest about. You are now the person holding the logs. I keep sessions and destination hostnames because the tunnel serves my own devices and I want to notice when something on the network starts talking to a place it should not. I keep AdGuard’s query log switched off, because its aggregate counters already tell me the blocklists are working, and a full per-query history is a thing I would rather simply not have. Log the metadata you would be comfortable with someone else holding about you, and rotate the per-device paths whenever a device leaves your hands. --- ### Restoring a Lost Database from .frm Files with Python 3 & mysqlfrm Published 2024-10-14 · 5 min read · Tags: Python, MySQL, Recovery Read at https://ilhamriski.com/blog/restoring-a-lost-database-from-frm-files/ Source code: https://github.com/IlhamriSKY/Database-Restore-Frm-Python-3-using-mysqlfrm Also on Medium: https://medium.com/@ilhamrisky21/restore-database-from-frm-and-ibd-files-easy-way-using-laragon-mysql-8b8d8cb73ffe > My database was lost to a bad server setting. Here is the simplest method I found to restore many InnoDB tables at once, using a short Python script and the mysqlfrm tool. My database was wiped out by a bad server setting. Digging through forums, I kept running into the same two files, `.frm` and `.ibd`, so here is the simplest way I found to bring many tables back at once. A `.frm` file stores an InnoDB table’s structure, while the matching `.ibd` file holds the raw table data. The `.ibd` can’t be read as a database on its own, so the structure has to be rebuilt first, then the data imported into it. The method below leans on a short Python script and [mysqlfrm](https://downloads.mysql.com/archives/utilities/), a tool from the MySQL Utilities archive. Because it loops over the folder, it rebuilds every table in one pass. #### What you’ll need 1. The `.frm` and `.ibd` files. On my CentOS 7 server they lived under `/var/lib/mysql/`. 2. A working folder to copy them into, e.g. `C:\Users\ilham\Documents\`. 3. The [mysqlfrm](https://downloads.mysql.com/archives/utilities/) tool from the MySQL Utilities archive. 4. A local MySQL server, installed and running (I used [Laragon](https://laragon.org/)). #### Step 1 · Export each table structure Loop over every `.frm` in the folder and run `mysqlfrm` against your local server, writing the output to one `.sql` file per table. ![Python cmd_restore() function looping over .frm files and running mysqlfrm](https://ilhamriski.com/assets/blog/restoring-a-lost-database-from-frm-files/01-run-mysqlfrm.png) *cmd_restore() runs mysqlfrm on every .frm file and saves one .sql per table.* #### Step 2 · Clean up the output mysqlfrm wraps the `CREATE TABLE` statement in extra log text, so the raw `.sql` won’t run as-is. Strip those lines so each file holds a single, valid query. ![Python delete_list() and delete_text() functions stripping mysqlfrm log lines](https://ilhamriski.com/assets/blog/restoring-a-lost-database-from-frm-files/02-clean-sql-output.png) *delete_text() removes the mysqlfrm log lines, leaving a runnable statement.* Then run the cleaner across the whole output folder. ![Python prosess() function cleaning every .sql file in the output folder](https://ilhamriski.com/assets/blog/restoring-a-lost-database-from-frm-files/03-run-script.png) *prosess() cleans each .sql in the output folder and re-saves it as .txt.* #### Step 3 · Recreate the tables With the structures cleaned up, connect to a fresh database and execute each query. I used [pymysql](https://github.com/PyMySQL/PyMySQL), swapping the old database name for the new one as each file is read. ![Python pymysql connection reading each query and executing it against the new database](https://ilhamriski.com/assets/blog/restoring-a-lost-database-from-frm-files/04-pymysql-import.png) *pymysql reads each cleaned query and runs it against the new database.* #### Step 4 · Import the data The tables exist now, but they’re empty. Copy the original `.ibd` files into the new database folder (with Laragon mine sit at `C:\laragon\data\mysql\`), then import each tablespace: ```sql ALTER TABLE `table_name` IMPORT TABLESPACE; ``` Run it the same way you ran the structure queries. Once every tablespace is imported, the database is back, with structure and data intact. The full script lives on [GitHub](https://github.com/IlhamriSKY/Database-Restore-Frm-Python-3-using-mysqlfrm) and I keep it updated. This walkthrough was [originally published on Medium](https://medium.com/@ilhamrisky21/restore-database-from-frm-and-ibd-files-easy-way-using-laragon-mysql-8b8d8cb73ffe). Grab the code with the buttons below. --- ### A Complex Datatable in Vue: search, sort, filter & export, server-side Published 2024-10-14 · 3 min read · Tags: Vue, Laravel, DataTable Read at https://ilhamriski.com/blog/complex-datatable-in-vue-server-side/ > Building a datatable with advanced search, ordering, dynamic filtering, and multiple export options, rendered server-side from a Laravel API for efficient, scalable performance. I’ve been building a complex datatable with advanced search, ordering, sorting, and dynamic filtering in Vue, plus multiple export options, to make large datasets easier to work with. Everything, including the dropdown filters, is rendered server-side from a Laravel backend API, which keeps it efficient and scalable even as the dataset grows. It was a genuinely fun build: watching the moving parts (query building, pagination, exports) come together into one smooth component. ## About this site https://ilhamriski.com is a static site: Astro 5, React 19, TypeScript, and Tailwind CSS, prerendered and served by nginx. The design system is shared with TEDI, his desktop app. The GitHub section is fetched live through a small PHP proxy, and the terminal on the home page answers questions about Ilham through an AI endpoint. Machine-readable entry points: /llms.txt, /llms-full.txt, /sitemap.xml, /robots.txt, and JSON-LD (Person, WebSite, ProfilePage, Article) embedded in every page.