What it is
We built a residential client management portal to replace a patchwork of spreadsheets and eleven separate SharePoint lists that a residential program team had been using to track clients, housing assignments, financial records, and regulatory recertification. It runs on Blazor Server and integrates directly with the organization’s clinical record system, pulling in client and admission data automatically rather than requiring manual re-entry.
Why it mattered
Before this system existed, the same client record effectively lived in several disconnected places at once, a spreadsheet for financial tracking, a SharePoint list for housing assignments, another for compliance deadlines, with no reliable way to confirm any two of them agreed with each other. Recertification deadlines for subsidized housing had to be tracked manually against a moving eligibility window, and a missed deadline carried real regulatory and funding consequences.
Consolidating everything into one system with a single source of truth was the actual goal, not just digitizing what already existed. Every field needed a clear answer to who could see it, who could edit it, and what happened when the clinical record system reported a change that conflicted with something staff had entered by hand.
Why I built this one alone
The context matters for understanding what this project actually was.
Our main development team was committed to an HL7 integration with a county payer, which is exacting work with an external deadline and no slack in it. The residential programme’s need was real and would otherwise have waited behind that. So I took it on myself, deliberately, as a test of whether an AI-assisted approach could produce a genuine production application rather than a demonstration.
I should be direct about my own starting position. I worked as a web programmer roughly twenty years ago. My career since has been reporting, data architecture, and database work, which means I can read code, reason about architecture, and review a design, but writing a modern web application by hand was not realistically available to me. Without an agentic coding tool, this application does not get built. That is not modesty; it is the actual precondition, and it is what makes the project a fair test of the tooling rather than a demonstration of how much faster a working developer can go.
Choosing the tool before starting
We did not default to a tool. Before this project began, we compared the options actually available to us: Microsoft Copilot, the chat assistant on Windows that I had already used for roughly eight months, Claude, and OpenAI’s tooling.
Copilot had earned real credit in that time, and its limits were clear from use rather than from reading about it. It was a chat window, which meant it could not read the codebase, could not compile or run anything, and kept nothing between sessions. Every line of code passed through me by hand, from paste to copy to editor to build.
What this project needed was something that could work across many files, run a build, and respond to what broke without me acting as the courier.
We selected Claude, and the timing lines up almost exactly with this project’s start. Applying a comparison before adoption is the same discipline that later produced the On-Premises AI Evaluation of seven local models: measure the options against the actual work rather than adopting the one with the most attention.
Both tools, doing different jobs
Selecting Claude for the build did not mean abandoning Copilot. It meant right-sizing it, and the resulting division of labour held for the whole project.
Claude Code did the implementation: the application code, the database work, the integrations. Copilot stayed in the role it had already proven good at, which was everything written for people rather than for a compiler.
That covered three things concretely. Status communication to leadership, where the useful work was expressing what had actually progressed and what was blocked, clearly enough that the next review could be scheduled against real progress rather than optimism. Structuring requirements gathered from the residential team, particularly the notification rules behind the two compliance workflows, turning a set of conversations into stated rules that could be built against. Rewriting technical explanations for non-technical audiences, which for a project with this many stakeholders was constant.
I find that division more interesting in hindsight than a single-tool story would be. It is a small illustration of the same point the local model evaluation reached at greater cost: the right question is not which tool is best, it is which tool for which task, and the answer is usually not the same one everywhere.
How my working method changed as trust was established
This is the part of the project I would most want another person in my position to read, because the method at the end looks nothing like the method at the start, and the change was gradual rather than decided.
At first I worked like a programmer with an assistant. Claude Code wrote code, I reviewed it as carefully as I could, then I moved to Visual Studio to compile, publish, and test by hand. Every cycle went through me at every stage. The iterations were long, and a meaningful share of each one was me verifying work I did not have the current expertise to verify quickly.
As I learned what the tool could actually be trusted with, I moved up a level. Claude Code took over writing, compiling, building, and publishing. That was not a decision made on faith; it followed from watching where mistakes actually occurred. My line-by-line review was catching very little that a compile and a real test would not catch faster, and it was consuming the time I should have been spending on the questions only I could answer.
What I kept was everything that required judgment about the problem rather than the code. Testing real functionality against what staff actually do. Boundary conditions, which is where most of the genuine defects on this project were found. Working with the analysts and the project manager on what the application needed to do at all. Deciding when something was wrong even though it worked.
Across this project I was, in effect, the developer, the architect, the product manager, and the database administrator. Most of the implementation was done by Claude. Both of those statements are true simultaneously, and I think stating them together is more useful than emphasising either alone.
The product-manager half of that was not nominal. It meant gathering notification rules from the residential team and turning conversations into stated requirements, running a review cadence with the executive sponsor where the next meeting was scheduled against actual progress, and translating between what the application did technically and what it meant operationally for the people using it. On a project where implementation is largely delegated, that work is not overhead around the build. It is most of the remaining job.
What this did not speed up
An honest counterweight, and the thing I would say first if someone concluded from this site that AI-assisted development compresses delivery timelines generally.
Getting an application to the point where an operational team fully relies on it takes roughly a year in an organisation this size, and this one is partway through. Not because writing the code takes a year, but because a small team has long lead times for anything entering production, and those lead times are almost entirely independent of how the code gets written. Review cycles. Access and security approval. Coordinating with people whose main job is not this project. Testing against real workflows with the people who perform them. Training. Waiting for the right moment in an operational calendar to change a system people depend on.
None of that got faster. AI assistance removed the constraint that would otherwise have prevented this application from existing at all, given who was available to build it. It did not remove the organisational constraints around putting software into production, and I would be suspicious of anyone claiming otherwise.
How the project grew, phase by phase
The earliest sessions built the database structure and imported the starting dataset directly from the SharePoint lists and the clinical record system, giving the project a real baseline to build against rather than starting from an empty schema. The application reached its first production deployment within the first week, intentionally early, so configuration problems specific to the actual hosting environment surfaced immediately rather than staying hidden until a later, larger launch.
An early architectural decision reframed what a “Site” meant in the data model, from a purely physical location to the actual fiscal billing unit, with physical attributes layered on top rather than the reverse. That reframing took a full session to specify before any code was written, since it touched how fiscal codes, addresses, and bed availability all related to each other, and getting the foundation wrong would have been expensive to unwind later.
Role-based access control and field-level permissions came next, driven by a configurable table rather than hardcoded checks scattered through the codebase, so a single admin screen controls whether any given field is editable, visible, or hidden per role. Financial tracking, document attachments through the organization’s file-sharing platform, and a case management module for tracking client-related matters and issues were each added as their own phase, with the case management module later renamed and restructured once real usage showed the original terminology and structure didn’t fit how staff actually used it.
A recurring scheduled-job system went through a full migration partway through the project, from one job scheduling library to another, touching every recurring job in the application at once, HUD recertification reminders, inactivity follow-ups, error alerting, all cut over in a single coordinated session once every job had been reimplemented and verified, rather than run in parallel with the old system during a slower rollout.
Design decisions worth explaining
Data from the clinical record system is never applied automatically. Every field sourced from that system gets queued for staff review before it takes effect, rather than silently overwriting whatever a staff member had entered by hand. This mattered most for discharge dates specifically: an automatic discharge could remove a client from active lists before anyone had actually reviewed whether it should take effect, so I deliberately excluded discharge dates from the auto-apply path entirely, requiring an explicit staff acknowledgment through a visible banner before a discharge is considered final.
Impersonation needed hard guardrails, not just convention. An administrator can view the application as another role for support and testing, which is a genuinely useful capability, but also a real risk if left unguarded. I built the restriction directly into the impersonation logic itself rather than trusting people to use it responsibly: an admin cannot impersonate another admin, and cannot impersonate themselves, closing off the two ways the feature could otherwise be used to quietly assume more access than intended.
A three-tier deployment gate applies to every change, without exception. Every code change publishes to a development server first, waits for explicit sign-off after real testing, and only then goes to the remaining servers together. This was tested directly partway through the project, when a database column removal was prepared and a stricter reading of an existing deployment rule was applied without waiting for that first sign-off. The correction that followed was explicit: the three-tier gate is a hard requirement for every change, with no exception for schema changes that feel urgent to finish quickly.
Handling a source system that doesn’t always agree with itself
Timestamps were an early and recurring source of quiet bugs. The application originally converted timestamps to a specific local time zone in the C# code and in column defaults, which worked until a stored procedure was found writing several timestamps directly at the database level, bypassing both of those safeguards entirely, since a raw database write doesn’t pass through a column default the same way an application insert does. The fix was a single shared time-zone-aware function used everywhere a timestamp gets written, application code and stored procedures alike, rather than trusting every write path to individually remember to convert correctly.
A related class of bug came from a scheduled synchronization job that ran nightly against the clinical record system. At one point it was found to be silently overwriting housing occupancy data on every run, discarding notes staff had entered by hand earlier that same day. The fix narrowed exactly what the nightly sync is allowed to touch, rather than letting it freely overwrite an entire table on every run.
Bugs and turning points along the way
A handful of moments from this project’s history are worth calling out specifically, since each one produced a lasting rule rather than a one-time fix.
- A boolean field quietly defaulted to the wrong value. A field meant to mark new financial and audit records as active was silently saving as inactive across several record types, since the C# boolean type defaults to false while the database column itself defaulted to true. Explicitly setting the value in code, at every affected save path, became the standing rule rather than relying on either language’s default to agree with the other.
- A git revert accidentally deleted two entire feature areas. The financial and certification tracking tabs disappeared from the application after an unrelated revert, and reconstructing them from version history while reconciling newer work built in the meantime took real care to avoid losing anything twice.
- Blazor would not evaluate an inline conditional inside a style attribute. A dynamic style needed to change based on data state, and the templating engine could not correctly handle a conditional expression written directly inside the attribute. Moving the logic into an ordinary method that returns the finished style string, rather than trying to inline it, became the standard pattern for every dynamic style afterward.
- A structured logging change nearly repeated an old performance problem. A previous attempt to add detailed request logging had slowed down the application’s real-time connection layer noticeably. Before making a similar change again, that specific risk was flagged explicitly ahead of time, and the new logging was scoped to avoid logging every real-time interaction at a verbose level.
- A LINQ query worked in every case except the one that mattered. A scheduled job grouping error log entries for a daily digest email used a query shape that could not be translated into SQL by the database library being used, only surfacing the failure once the job actually ran against real data. Rewriting the grouping logic to run in memory rather than at the database level resolved it.
- A direct-URL loophole bypassed page-level access control. A restriction that correctly blocked one category of staff from browsing another category’s client list did not extend to the underlying detail page itself, so a direct link could reach a record that the list view would never have surfaced. The fix moved the same check down to the one place every access path actually passes through, rather than trusting every entry point to repeat it correctly.
The request intake feature, and a decision I reversed
The application includes a staff request intake system: anyone can submit an issue or enhancement request, an approver triages it, and approved items become real tickets in the organisation’s service desk. The route to that design is worth describing because it involved abandoning a plan I had already worked out in detail.
The plan I threw away
The original design was to self-host Plane, an open-source project management tool, on one of our SQL servers using WSL2 and Docker. I had the plan fully specified: seventeen setup steps, a reboot window, a half-day infrastructure session, and ongoing maintenance responsibility thereafter.
The reason for choosing a self-hosted tool was data residency. Requests would contain internal detail, and keeping them on our own infrastructure felt like the responsible default.
Then I re-examined the premise rather than the plan, and it did not hold. The organisation had already adopted Freshservice as its company-wide IT ticketing system, and in doing so had already accepted its cloud hosting for exactly this category of data. The concern driving me toward a self-hosted tool had been resolved organisationally before I started, and I had not checked.
Once that was clear, the comparison was one-sided:
| Self-hosted Plane | Freshservice | |
|---|---|---|
| Licence and cost | New tool to justify | Already owned |
| Staff onboarding | New interface to learn | Already in daily use |
| Infrastructure | WSL2, Docker, reboot window, ongoing upkeep | None, cloud-hosted |
| Integration | REST API | REST API |
| Data residency | On premises | Cloud, already accepted org-wide |
An entire infrastructure workstream disappeared, and the feature got better rather than worse, because staff were already familiar with where the tickets would land.
The lesson I took is not about either product. It is that I had spent real effort designing a solution to a constraint that had already been lifted, and the only thing that surfaced it was going back to check why the constraint existed. That is now a habit: before building around a restriction, confirm the restriction is current.
Designed, stubbed, then activated
The integration could not be built when it was designed, because API access was disabled at the account level and enabling it required a request to the service desk administrator.
Rather than let that block the feature, I split it along the dependency. The submission form, the triage interface with approve, park, and reject paths, the local status tracking, and the request history view were all built and shipped while the API remained unavailable. Three pieces were explicitly stubbed and documented as stubs: ticket creation on approval, the status sync back from the service desk, and the closure notification email.
That decomposition meant staff got a working intake and triage system immediately, with a documented list of exactly what would light up when credentials arrived. When the API key did arrive, wiring the three stubbed pieces was a contained piece of work rather than the start of the feature.
What the finished integration does
On approval, a ticket is created automatically in the service desk, with the submitter’s name and email and the approver’s name embedded in the description so the receiving technician has the full context without asking. A technician is assigned automatically. A recurring job slides the target completion date forward each week for anything still awaiting approval, so stale requests surface rather than silently ageing.
The page shows a single unified queue combining locally submitted requests and tickets pulled live from the service desk, with a badge distinguishing which system each item came from. That was a deliberate choice over two separate lists, because an approver’s actual question is what needs attention, not which system something happens to live in.
One API detail worth recording. Retrieving the right tickets initially filtered by the agent’s email address, which returned the wrong set. The working approach is a two-step lookup: resolve the email to a numeric agent identifier first, then query tickets by that identifier. An identifier that looks interchangeable with an email address in the documentation frequently is not.
Migrating the entire scheduled job system, in one cutover
A larger architectural change happened partway through this project, and it is the origin of a standing rule I now apply to every .NET project.
The application’s recurring jobs were originally built on Hangfire. Over a stretch of sessions I migrated every one of them to Quartz.NET and removed Hangfire entirely, including dropping its database tables.
The choice was between staging the migration, running both schedulers in parallel while jobs moved across one at a time, and doing a single cutover once every job had been reimplemented and verified. I chose the single cutover. Two schedulers running concurrently against the same jobs is a class of problem I would rather not debug, and the verification work was the same either way.
The migration also surfaced a bug worth recording, because it only appears at runtime. The error digest job grouped log entries using a query shape the data access layer could not translate into SQL. It compiled cleanly and failed only when the job actually executed against real data. Rewriting the grouping to run in memory rather than at the database level resolved it, and the general lesson is that a query which compiles is not a query which runs.
A visible administrative view of every scheduled job, its next fire time, and a manual trigger button came out of the same work. Later, that pattern became its own application, described in the Job Scheduler project, so that jobs did not have to live inside whichever application happened to need them first.
A security review, and what it found
A full security sweep ran as its own session rather than as a step inside feature work, which I think is the only way it actually happens.
The package scan came back clean, with no vulnerable dependencies. The review also confirmed no SQL injection exposure: application queries are parameterised throughout, the external clinical database connection uses static queries, and the directory lookup uses a safe identity resolution call rather than a constructed filter.
Three findings were fixed and shipped in that session:
- An API key was living in configuration files rather than in the database configuration table where the application’s other secrets are managed. Moved.
- Rich text submitted through the request form was stored without sanitisation. The submission form accepts formatted text and pasted images, which means it accepts markup, which means stored cross-site scripting is a real exposure rather than a theoretical one. Now sanitised on the way in.
- A password field and social security numbers were appearing unmasked in the audit log. Masked.
Two lower-severity findings were deliberately deferred to a dedicated session rather than rushed, on the reasoning that each was broad in scope and attempting them immediately before a production push was the larger risk. I would rather record a deferred finding with a reason than quietly leave it undocumented.
What the application actually looks like
Describing an application only by its architecture undersells the amount of the work that is interface, so it is worth naming what is actually there.
The application opens on a dashboard scoped to the signed-in person’s role, so a director sees their own program rather than everything. From there:
- A client list with role-dependent columns, since different roles need different identifiers visible, plus search and filtering through a shared filter component.
- Client detail across eight tabs — profile, residency, financial, certifications, documents, case matters, and related views. Each tab loads independently with its own spinner, and any tab showing a discharged client displays a standing warning banner reminding staff that edits are audited.
- Housing management — sites, units, and beds, with a vacant-units view offering quick assignment, and a movement history recording every bed change.
- Worklists for the two recurring compliance deadlines, each with a show-completed toggle and a date range so staff can look back rather than only forward.
- A conflicts page showing every field where the clinical record system disagrees with what staff entered, which is the review queue that makes the never-auto-apply rule workable rather than merely safe.
- An admin area covering users, role permissions, field-level permissions, scheduled job status, and application logs.
- A request intake page where staff submit issues through a rich-text form that becomes a triaged queue and, on approval, an actual ticket in the organization’s service desk platform, Freshservice. That integration has its own section below, because how it got there is more interesting than what it does.
Several interface decisions came from real usage rather than design intent. A shared filter bar exists because four list pages had each grown their own search-and-debounce logic. Negative financial amounts are flagged with both a red background and an icon rather than color alone. A confirmation step intercepts editing a closed case matter, inline rather than as a modal, to match the existing delete-confirmation pattern.
The help documentation, and how it got built
The in-app help is one of the larger pieces of work in the project and the easiest to overlook, because documentation rarely shows up in an architecture diagram.
It runs to twenty-eight sections with roughly thirty annotated screenshots, covering every page and every meaningful revealed state, with a sidebar navigation, a floating return-to-top control, and a help icon on every page in the application that deep-links into the relevant section rather than dumping the reader at the top.
It did not start there. The first version was a single static HTML file, which became difficult to maintain and did not participate in the application’s own navigation, authentication, or theming. Rebuilding it as a proper page inside the application took two consecutive working days and produced a set of problems worth recording:
- Table-of-contents anchors did not work under the framework’s routing, and had to be rewritten as full paths with a fragment rather than bare anchors.
- Clicking a navigation link sometimes froze the scroll position, fixed by making navigation scroll to top unconditionally.
- The floating return-to-top control flickered or failed to appear on some section transitions. The eventual fix was deleting the scroll-tracking logic entirely in favor of an always-visible control with no opacity animation, which is a good example of a fix that removes code rather than adding it.
- Screenshots containing real client data had to be replaced. Earlier images captured live screens. Every one was regenerated against redacted data, because a help page is exactly the kind of artifact that gets shared more widely than the application itself.
The screenshot capture is scripted rather than manual, driven by a reusable tool that walks the application, captures each page and each revealed state, and applies numbered callout annotations. That matters for maintenance: a help page with thirty hand-captured screenshots decays the moment the interface changes, and one that can be regenerated does not.
One process lesson came out of it that generalizes well beyond this project. A tool that reports what controls currently exist on a page cannot detect what has been removed. One help section documented a card with a numbered callout weeks after that card had been deleted. The fix was an explicit removal check: for each changed page, open the existing help content and verify every documented element still has a match in the current markup, dropping any that does not.
Timeline and effort
Two different numbers describe this project, and the gap between them is the most useful thing in this section.
Recorded development activity spans roughly 18 weeks, the longest of any project in this set. The project’s own record shows 51 distinct working days in that period, several covering more than one internally numbered session, with the running count passing 75 by the most recent entry.
Reaching the point where an operational team fully relies on it is roughly a year’s work, and this application is partway through. It went into production early and has kept improving since, but the distance between working software and software a team depends on without thinking about it is measured in months of review, approval, real-workflow testing, training, and waiting for safe moments in an operational calendar.
Quoting only the first number would overstate what has happened. Quoting only the second would understate it. The honest version is that development stopped being the bottleneck, and everything that is not development did not get faster.
Where it stands now
The application has been in active production use since its first deployment in the project’s opening days, and every phase described above shipped and has been running in production since. Development is ongoing, with the next planned phase migrating the remaining scheduled jobs to shared scheduling infrastructure already serving other internal applications, so this system no longer needs to run its own copy of that machinery once the migration is verified.