The Architecture of FlowBoard: Building an Agency OS
FlowBoard is the internal operating system we built to run Harbor Software’s agency operations. It manages client projects, team capacity, time tracking, invoicing, resource allocation, and reporting across 15-20 concurrent client engagements. This post is a technical deep dive into how we designed and built it, the architectural decisions that worked, and the ones that did not.
We built FlowBoard because no existing tool—not Asana, not Monday.com, not ClickUp, not Linear—solved the specific problem of managing a software agency where every project has different technology stacks, billing structures, team compositions, and delivery timelines. Off-the-shelf project management tools are designed for product teams that work on one product. Agencies work on 15 products simultaneously, and the operational complexity is fundamentally different. You need to answer questions like “if we take on this new project, which engineer gets pulled from which existing project, and what does that do to the delivery timeline?” No generic project management tool answers that question, because it requires integrated data about team skills, project requirements, current allocations, and contract deadlines.
Core Requirements That Drove the Architecture
Before writing any code, we spent two weeks documenting the operational workflows that FlowBoard needed to support. We interviewed every team lead, shadowed the operations manager for a full week, and mapped every spreadsheet, Slack workflow, and manual process that kept the agency running. The non-obvious requirements that shaped the architecture:
- Multi-tenant time tracking with billing rule engines. Different clients have different billing arrangements: hourly, fixed-price, retainer with rollover, time-and-materials with caps, blended rates that change based on seniority. The time tracking system needed to apply the correct billing rules per client, per project, per engineer, with support for rate cards that change mid-project. Our simplest client has a flat hourly rate. Our most complex client has a tiered rate card with 6 rate levels, a monthly hours cap, a quarterly true-up provision, and different rates for weekday vs. weekend work. Both billing configurations need to be expressible in the same system without special-casing.
- Capacity planning with skill matching. When a new project comes in, the system needs to answer: “Who is available, and do they have the right skills?” This requires modeling each engineer’s skill set (with proficiency levels—there is a difference between “knows React” and “can architect a complex React application”), current allocation across projects (in hours per week, not just binary assigned/unassigned), planned time off (vacation, holidays, training days), and project-specific requirements (“needs TypeScript and PostgreSQL experience, plus domain knowledge in healthcare”).
- Revenue forecasting. At any point, we need to know: what is our projected revenue for the next 3 months, based on current contracts, pipeline, and resource availability? This is a calculation that combines contract values, burn rates, team capacity, and probability-weighted pipeline data. The forecast needs to be accurate enough to inform hiring decisions (“we need another senior engineer in 6 weeks”) and cash flow planning (“next month’s payroll is covered by confirmed revenue”).
- Automated client reporting. Every client gets a weekly status report. Generating these manually for 15-20 clients consumed 8-10 hours per week. FlowBoard generates draft reports from time entries, task completions, and milestone progress, then presents them for human review and customization before sending.
Technology Choices and Why
FlowBoard is built on Next.js 14 (App Router) with PostgreSQL, deployed on Vercel with a Neon database. The choice of Next.js was deliberate and based on specific technical requirements, not framework hype:
We wanted server-side rendering for the dashboards (initial page load speed matters when you are checking project status on your phone at 7 AM), React Server Components for data-heavy pages that do not need client-side interactivity (the capacity planning view renders a Gantt chart with data from 20 projects and 15 engineers—that is a lot of data that should be fetched and rendered on the server, not shipped to the client as JSON), and the App Router’s layout system for the nested navigation pattern that an operational dashboard requires (persistent sidebar, project-scoped views, tab-based sub-navigation within each project).
// app/projects/[projectId]/layout.tsx
export default async function ProjectLayout({
children,
params,
}: {
children: React.ReactNode;
params: { projectId: string };
}) {
const project = await getProject(params.projectId);
const team = await getProjectTeam(params.projectId);
const health = await calculateProjectHealth(params.projectId);
return (
<div className="grid grid-cols-[280px_1fr] h-screen">
<ProjectSidebar
project={project}
team={team}
health={health}
/>
<main className="overflow-y-auto p-6">
{children}
</main>
</div>
);
}
The project layout loads once and persists as you navigate between tabs (overview, tasks, time, budget, reports). The sidebar shows the project health indicator (a composite score based on budget burn rate, milestone progress, and blocker count), team members with their allocation percentages, and budget burn rate—information that contextualizes every other view. React Server Components mean the sidebar data is fetched on the server and streamed to the client, with zero JavaScript bundle cost for the static parts of that data.
For the database, we chose PostgreSQL with Drizzle ORM. We chose Drizzle over Prisma for two specific reasons: Drizzle generates SQL that is easier to debug and optimize (Prisma’s query engine sometimes generates suboptimal queries for complex aggregations, and when you need to debug a slow query, having readable generated SQL matters), and Drizzle’s TypeScript inference is more precise (you get the exact column types back, not approximations that require runtime casting). For a system that does a lot of aggregation queries (total hours per project per week, revenue by client by month, utilization rate by engineer), the ability to write and debug raw SQL when needed was essential.
// Capacity utilization query - would be painful in any ORM
const utilization = await db.execute(sql`
WITH allocated AS (
SELECT
e.id AS engineer_id,
e.name,
e.role,
SUM(pa.hours_per_week) AS allocated_hours
FROM engineers e
LEFT JOIN project_allocations pa
ON pa.engineer_id = e.id
AND pa.start_date <= CURRENT_DATE
AND pa.end_date >= CURRENT_DATE
WHERE e.active = true
GROUP BY e.id, e.name, e.role
)
SELECT
engineer_id,
name,
role,
COALESCE(allocated_hours, 0) AS allocated,
40 AS capacity,
ROUND(COALESCE(allocated_hours, 0) / 40.0 * 100, 1) AS utilization_pct
FROM allocated
ORDER BY utilization_pct DESC
`);
The Billing Engine: The Hardest Component
Time tracking is straightforward—it is a CRUD operation on a time entries table with some validation (hours must be positive, date must not be in the future, project must be active). Billing is not straightforward. The billing engine is the most complex component in FlowBoard because it must handle every billing arrangement we have ever encountered, and new arrangements appear regularly as we take on different types of clients.
We modeled billing as a rule engine rather than a fixed set of billing types. Each project has a billing configuration that specifies:
- Rate type: hourly, daily, fixed, or retainer
- Rate card: per-role rates (senior engineer: $185/hr, designer: $150/hr, etc.) with effective dates for rate changes
- Caps and thresholds: monthly hour caps, annual budget caps, notification thresholds at 75% and 90%
- Rollover rules: for retainers, whether unused hours roll over, and if so, for how many months and at what cap
- Approval workflow: whether time entries require client approval before billing, and the approval timeout (auto-approve after 5 business days if no response)
- Invoice schedule: monthly, bi-weekly, on milestone completion, or custom dates
// billing-engine.ts
interface BillingConfig {
type: "hourly" | "daily" | "fixed" | "retainer";
rateCard: RateCardEntry[];
caps: { monthly?: number; annual?: number };
rollover: { enabled: boolean; maxMonths: number; maxHours: number };
approvalRequired: boolean;
approvalTimeoutDays: number;
invoiceSchedule: "monthly" | "biweekly" | "milestone" | "custom";
}
interface RateCardEntry {
role: string;
rate: number; // cents, to avoid floating point
currency: "USD" | "PKR" | "GBP";
effectiveFrom: Date;
effectiveTo: Date | null;
}
function calculateInvoiceAmount(
timeEntries: TimeEntry[],
config: BillingConfig,
): InvoiceCalculation {
const lineItems: InvoiceLineItem[] = [];
for (const entry of timeEntries) {
const rate = findApplicableRate(
config.rateCard,
entry.role,
entry.date
);
if (!rate) {
throw new BillingError(
`No rate found for role ${entry.role} on ${entry.date}`
);
}
const amount = Math.round(entry.hours * rate.rate * 100) / 100;
lineItems.push({
description: `${entry.engineerName} - ${entry.taskSummary}`,
hours: entry.hours,
rate: rate.rate,
amount,
date: entry.date,
});
}
const subtotal = lineItems.reduce((sum, li) => sum + li.amount, 0);
const cappedAmount = applyCaps(subtotal, config.caps);
return { lineItems, subtotal, cappedAmount, config };
}
The billing engine operates in cents (integer arithmetic) to avoid floating-point errors. We learned this the hard way: a floating-point rounding error of $0.01 per line item, across 200 line items on a monthly invoice, produced a $2.00 discrepancy that took our accountant 4 hours to track down. She was not impressed. Integer arithmetic eliminates this class of bug entirely. All currency values are stored as integers in the database (cents for USD, paisa for PKR) and converted to decimal only for display.
The rate lookup is time-aware: the findApplicableRate function finds the rate card entry that was effective on the date of the time entry, not the current date. This means if a client’s rate increases on March 1, time entries from February are billed at the old rate and time entries from March are billed at the new rate, even if the invoice is generated on March 15. Getting this wrong (billing February work at March rates) is a common billing system bug that damages client trust.
Automated Reporting with LLM-Assisted Drafts
The weekly client reporting workflow was consuming 8-10 hours per week of senior engineer time—our most expensive resource. FlowBoard automates this by generating draft reports from structured data, then presenting them for human review.
The data sources for a weekly report:
- Time entries logged against the project that week, categorized by task area
- Tasks completed (status changed to “done”), with descriptions
- Tasks started (newly assigned that week), with estimated completion dates
- Milestone progress (percentage complete of current milestone, compared to planned percentage)
- Budget burn rate (hours consumed vs. budgeted, both for the current milestone and overall project)
- Blockers flagged by team members, with age (how long the blocker has been open)
An LLM synthesizes this structured data into a professional narrative. The prompt is specific and opinionated about format, because inconsistent report formatting was one of the complaints that drove us to automate in the first place:
const reportPrompt = `Generate a weekly project status report from the following data.
Format:
1. Executive Summary (2-3 sentences: what happened, what's next, any risks)
2. Completed This Week (bullet points, specific deliverables)
3. In Progress (bullet points with expected completion dates)
4. Blockers and Risks (only if present, with recommended actions)
5. Budget Status (one line: X of Y hours used, Z% of budget consumed)
6. Next Week Focus (2-3 priorities)
Rules:
- Use the client's name, not "the client"
- Reference specific feature names and technical components
- Do not use filler phrases like "great progress" or "moving forward"
- If budget is above 80% consumed with milestone below 80% complete, flag it
- Keep total length under 400 words
- Use past tense for completed work, present tense for in-progress
Project data:
${JSON.stringify(weeklyData, null, 2)}`;
The generated draft is 85-90% usable as-is. The project manager reviews it, adds context that the data does not capture (“we discussed changing the authentication approach during Thursday’s call and agreed to switch from Clerk to Supabase Auth”), corrects any mischaracterizations (the LLM occasionally misstates the significance of a task), and sends it. Total time per report dropped from 30-45 minutes to 5-10 minutes. Across 15 clients, that is a saving of 6-8 hours per week—nearly a full working day of senior engineer time redirected to billable work.
Capacity Planning: The Algorithm
When a new project opportunity arrives, the first question is: do we have the capacity and skills to take it on? FlowBoard’s capacity planner answers this by modeling team availability against project requirements.
Each engineer has a skill profile (languages, frameworks, domain expertise, each with a proficiency level from 1-5) and a weekly capacity (typically 40 hours, minus planned time off, minus standing commitments like internal meetings and professional development). Each project has a resource plan specifying required skills (with minimum proficiency levels), required hours per week, and duration. The capacity planner finds feasible team compositions that satisfy both the project requirements and the constraints of existing commitments.
This is a constraint satisfaction problem. We solve it with a greedy algorithm rather than an optimal solver (like integer linear programming) because the problem size is small enough (15-20 engineers, 15-20 projects) that a greedy approach produces good-enough solutions in milliseconds, while an ILP solver would be overkill and harder to debug. The greedy approach: for each required skill, sort eligible engineers by available capacity (descending), then by proficiency level (descending), and allocate the top candidate. If no engineer has sufficient capacity, flag the project as requiring a hire or a timeline adjustment.
The algorithm also generates a “what-if” analysis: if we take this project, what is the impact on existing projects? Which engineers get reallocated, and does that push any existing project past its risk threshold? This impact analysis is the single most valuable output of the capacity planner, because it makes the trade-offs visible before the commitment is made. Before FlowBoard, these trade-offs were negotiated in Slack threads and informal conversations, and important constraints were frequently missed until they became deadline crises.
The output is a Gantt-style visualization showing team allocations over the next 3 months, with color coding for utilization levels (green: under 80%, yellow: 80-95%, red: over 95%). This visualization is the single most-used screen in FlowBoard because it answers the question every agency owner asks daily: “Are we over-committed or under-utilized?”
What We Would Rebuild
FlowBoard is in its third major iteration. Here is what we got wrong in earlier versions and how we fixed it:
- Version 1 had a single monolithic API. Every request hit the same server, and a slow analytics query (calculating 3-month revenue projections across all projects) would block fast queries (fetching a single project’s status). A 2-second analytics query that runs when someone opens the revenue dashboard would degrade response times for everyone else using the app simultaneously. We split into two services: a fast CRUD service for everyday operations and an analytics service for reporting and forecasting. The fast service has p99 latency under 50ms. The analytics service has p99 around 800ms, which is acceptable for dashboard loads but would be terrible for button clicks.
- Version 2 used real-time sync for everything. We built the time tracking interface with real-time WebSocket updates so that you could see entries appearing as colleagues logged time. This was technically cool and operationally pointless—nobody watches the time tracking screen in real time. We replaced it with optimistic updates on the client side and eventual consistency (polling every 30 seconds), which is simpler, cheaper, and functionally identical from the user’s perspective. The lesson: real-time updates are a feature, not a default. Use them where they add value (collaborative editing, live dashboards during standups) and skip them everywhere else.
- Both versions underinvested in data export. Clients and accountants want CSV exports. Our accounting software wants Xero-compatible invoices. Our capacity data feeds into Google Sheets for board reporting. We initially treated data export as a secondary concern and hand-coded each export format as a one-off. Now we have a generic export layer that supports CSV, JSON, PDF, and Xero XML, and it was one of the highest-ROI investments in the codebase—small to build (about 300 lines of code for the generic exporter plus format-specific serializers), used constantly, and it eliminated a category of ad-hoc requests that were consuming 2-3 hours per week.
FlowBoard handles approximately $3 million in annual client billings, manages 15-20 concurrent projects, and has reduced our operational overhead by roughly 12 hours per week compared to the spreadsheet-and-email system it replaced. The total development investment was approximately 800 hours over 18 months. If you are running an agency with more than 5 concurrent clients and you are still using spreadsheets for capacity planning and manual processes for client reporting, the payback period for building (or buying) an operational system is measured in weeks, not months.