We're live in beta — earn up to 12,000 credits by signing up today. Get started
General

Write a business plan

Read this before writing a business plan, planning the next three to ten years of a business, setting growth targets, or working out what has to change for the owner to get where they want to go.

  • 0 installs
  • v1
  • Updated Sep 12, 2026
Written for
Show the connectors this skill is written for

Written and maintained by FloConnector. Install it as kept updated and your copy follows our revisions; install it as your own and it never changes unless you change it.

SKILL.md 12.3 KB

Write a business plan

A business plan is a document the owner uses to decide what to do. It is written for the people inside the business, and its whole test is whether anyone acts differently because it exists.

Most business plans are written for somebody else. They get produced when money is being raised, they contain a market size built by dividing a global figure, and once the money arrives or does not, nobody opens the file again. That document has its uses and it is not this one. If you need a pack for a bank or an investor, that is a different artefact with a different audience, and it should be built separately from this.

This plan starts somewhere else entirely: with what the owner actually wants their life to look like in five or ten years. Everything downstream, the revenue target, the hiring, the systems, the decision to take on a particular kind of work, is either serving that or quietly working against it. Most owners have never written it down, which is why so many end up with a successful business they cannot leave.

Ask these ten questions first

Do not draft anything until these are answered. The first three are the plan. The rest is arithmetic and sequencing.

Ask all ten at once, numbered, in a single message. Say that rough answers are fine, that “I do not know” is a real answer, and that question 1 is the one worth thinking about properly.

 1. Where do you want to be in five to ten years? Personally, not the business.
    Still running it day to day, stepped back, sold up, working three days?
 2. What would have to be true about the business for that to be possible?
 3. What do you want it to be worth, or what do you want it to pay you?
 4. What does the business look like today? Revenue, profit, headcount, and
    how many hours a week you are actually in it
 5. What does it genuinely do well, and where does it struggle?
 6. What is the one thing most stopping you from growing right now?
 7. Where do you want revenue and profit to be in three years?
 8. What would you have to stop doing to get there?
 9. What have you tried before that did not stick, and why?
10. Who else needs to be part of this, and do they know yet?

Question 1 is load bearing and it is the one people deflect. “I just want it to keep growing” is not an answer. Growth is a means. Owners who skip this build the wrong business very efficiently: they add headcount that ties them in harder, or take on work that pays well and that they hate. Push, warmly, until you get something concrete. How to do that is in references/where-you-are-going.md.

Question 6 is where the plan gets its shape. There is almost always one constraint, and work that does not address it produces nothing. If the constraint is the owner’s own time, no revenue target is achievable without changing that first.

Question 8 is the one that makes the plan real. A plan that only adds is a wish list. Capacity comes from somewhere.

Question 9 prevents repeating a failure. If they have tried to hire an estimator twice and it did not work, the plan needs to know that before proposing it a third time.

Do not ask for anything you can pull. Where Xero or QuickBooks is connected, take revenue, margin and trading history from the ledger and confirm the figures rather than asking. Asking an owner to recite numbers their accounting system already holds wastes the part of the conversation that matters.

The structure

1.  Where the owner is going     the point of the whole document
2.  Where the business is now    honest, including what is not working
3.  Where it needs to be         three years, and what that means concretely
4.  The gap and the constraint   what is actually in the way
5.  How we close it              three or four things, not twelve
6.  What we stop doing           capacity comes from somewhere
7.  The numbers                  today, three years out, assumptions visible
8.  This year                    the actions, with owners and dates
9.  What could derail it         four to six real ones
10. How this gets reviewed       quarterly, or it was a writing exercise

Section by section, and how each gets botched, in references/structure.md. A copy ready template is at assets/business-plan-template.md.

Sections 1, 6 and 10 are the ones missing from almost every business plan, and they are the three that decide whether it changes anything.

Where the owner is going

The first section, and the reason the document exists. One page at most.

It answers: what does the owner want their working life to look like in five to ten years, what does that require from the business, and by when.

By 2033 I want to be working two days a week on the commercial side only,
with someone else running day to day operations. I want the business to pay
me $220,000 a year whether or not I am in it, and I want it to be sellable
even if I never sell it.

That paragraph makes decisions. It says the business needs a general manager, documented systems, and revenue that does not depend on the owner’s relationships. It rules out the kind of growth that needs the owner on every job. A revenue target alone would have ruled out nothing.

Three common answers, and what each demands:

The owner wantsThe business has to become
To step back but keep itManageable by someone else. Systems, a second in charge, documented pricing
To sell in five to ten yearsNot dependent on the owner, with clean books and recurring revenue
To keep working but earn moreMore profitable rather than bigger. Often a pricing and mix problem
To hand it to familySame as selling, plus a real conversation with the family member

Notice that only one of those is mainly about growth. A plan that assumes bigger is the goal will produce the wrong plan for three of the four.

Be honest about where you are

The section people write carefully and vaguely. It is worth more written bluntly, because the plan is for you.

  • The real numbers, from the ledger, not from memory
  • What the business is genuinely good at, said specifically enough to build on
  • What is not working. The customer type that always loses money, the process that breaks every January, the person who is not in the right seat
  • How many hours the owner actually works, and on what. This is usually the finding
  • What the business depends on that would be a problem if it stopped. One customer, one supplier, one person, one referral source

If this section is comfortable to read, it is not finished.

Name the one constraint

Almost every business has a single thing limiting it. Work on anything else produces nothing until it is fixed.

ConstraintLooks likeUsually fixed by
Owner’s timeEverything routes through one personDelegating, pricing up, hiring, or refusing work
CashProfitable and always tightTerms, deposits, invoicing faster, pricing
PeopleWork turned away, or quality slippingRecruiting earlier, pay, training, retention
DemandCapacity sitting idleMarketing, sales, a different offer
MarginBusy and not making moneyPricing. Almost always pricing
SystemsGrows, then breaks, then recoversProcess and documentation before the next push

Pick one. A plan addressing four constraints at once addresses none. When the first is fixed, the next one becomes the constraint, which is fine and expected, and that is what the quarterly review is for.

Three to five things, not twelve

The gap between now and the three year picture gets closed by a small number of things. A plan with twelve initiatives is a plan where nothing gets finished.

Each one needs: what it is, why it matters for the three year picture, who owns it, when, and what tells you it worked.

Move from 70% reactive work to 55% contracted maintenance
  Why      Scheduled work is the only way to raise margin without more hours,
           and it is what makes the business sellable later
  Owner    Dale
  By       End FY27
  Measure  Contracted revenue as a share of the total, monthly

And say what you will not do. The things that were considered and deliberately parked belong in the document, because in eight months somebody will suggest them again.

The numbers

Three years, not five. Beyond three the forecast is invention and everyone treats it that way.

The point here is different from a funding document. Nobody is assessing your credit. The numbers exist to tell you whether the plan is possible and what it costs, and to be compared to reality later. Detail in references/financials.md.

Pull the history, do not retype it.

xero_get_profit_and_loss          xero_get_balance_sheet
quickbooks_get_profit_and_loss    quickbooks_get_balance_sheet

Every number traces to an assumption that is written down. Revenue is a count multiplied by a price. Say the count, say the price, say where each comes from. When actuals diverge in six months, the assumptions tell you which one was wrong, which is the entire value of writing them down.

Include what the owner takes out. A plan that quietly omits the owner’s salary or drawings is not modelling the business, and this is the one document where that self deception costs the most.

Model the downside. Halve the improvement on the assumption the whole plan rests on. If the business still works, proceed with confidence. If it does not, you have found the risk that matters.

This year

The part that gets acted on. Everything above it is context.

For each of the next four quarters: what happens, who owns it, and what number says it worked. Specific enough that somebody could pick it up and start on Monday.

If a section of the plan produces no action this year, it is either a later year’s problem or it does not belong.

Make it survive contact

A plan that is written and filed has cost time and delivered nothing. Detail in references/making-it-stick.md.

  • A quarterly review, scheduled before the plan is finished. Ninety minutes. Actuals against plan, what we learned, what changes
  • One owner per action. A person, not a department
  • Update it when it is wrong. A plan overtaken by events should be changed, not quietly ignored
  • Tell the team the parts that affect them. A plan only the owner has read cannot be executed by anyone else
  • Rewrite it annually, keeping the answer to question 1 unless the owner’s own goal has genuinely changed

Rules

  • Write it for yourself, plainly. No audience to impress means no reason to hedge
  • Short. Five to fifteen pages. Long plans do not get read, including by the person who wrote them
  • Every section earns its place by changing a decision. If nothing changes, cut it
  • Concrete beats comprehensive. Three things with owners and dates beat forty pages of analysis
  • Say what you do not know, and what would tell you
  • Revisit the owner’s goal first at every review. It is the one thing that makes the rest coherent

What not to do

  • Do not build it around a funding ask. That is a different document. Writing this one to double as a bank pack makes it dishonest in the places where honesty is the entire point
  • Do not invent a market size by slicing a global figure. Nobody internal is fooled, and the number will not guide a single decision
  • Do not forecast growth with no mechanism. If revenue rises 40%, say what causes it. Hiring two people is a mechanism
  • Do not plan twelve initiatives. Three to five, finished, beats twelve started
  • Do not write it alone if other people have to execute it. The plan is better and it actually happens
  • Do not skip the honest section. A plan built on a flattering picture of the business is a plan for a business that does not exist

Producing the document

Most internal plans should live somewhere editable. Render a PDF when it is being circulated to a team, a board, or a family member who is part of the decision:

node scripts/render_pdf.mjs business-plan.md --brand=brand.json

Needs Node 18 or newer and Chrome or Edge, and nothing installed. Copy assets/brand.example.json and change the values. Detail in references/rendering-the-pdf.md.

Right align every column of numbers, using a colon at the end of that column’s delimiter row.

Reference files

Everything the skill tells your AI to read, exactly as it ships in the zip.

references/financials.md 6.3 KB
# The numbers

Different job from the numbers in a funding document. Nobody is assessing your credit. These exist to answer two questions: **is the plan actually possible, and what does it cost?** Then to be compared against reality every quarter, which is where they earn their keep.

That changes what good looks like. A funding forecast is built to survive scrutiny. This one is built to be right, which means the conservative version is the useful version and there is no reason to present anything else.

## Start from the real history

**Pull it, do not retype it.**

**Xero**

```
xero_get_profit_and_loss     by month, and against the same period last year
xero_get_balance_sheet
xero_list_invoices           type ACCREC, for revenue concentration by customer
xero_list_accounts           so the forecast uses the same account names
```

**QuickBooks**

```
quickbooks_get_profit_and_loss
quickbooks_get_balance_sheet
quickbooks_list_accounts
```

Two habits worth keeping:

- **Use the same account names as the ledger.** You will be comparing forecast to actual every quarter, and inventing tidier categories for the plan makes that a manual reconciliation every time
- **Take three years if they exist.** One year is an anecdote. Three shows a trend, including an unwelcome one

QuickBooks meters API reads and Xero rate limits per tenant, so pull each report once at the period you need rather than looping month by month.

## Revenue concentration, which most owners underestimate

Worth calculating explicitly, because the answer is routinely worse than the owner's estimate and it bears directly on almost every version of question 1.

```
Customer              Revenue    Share
Calder Property       312,000     21%
Westbrook Aged Care   188,000     13%
Hanley Builders       141,000      9%
Everyone else         857,000     57%
```

If the owner wants to sell, this table is the first thing a buyer discounts. If they want to step back, it tells you which relationships have to be transferred and in what order. If they want neither, it is still the largest single risk in most small businesses.

## Three years, not five

Beyond three the numbers are invention, and an internal plan gains nothing from pretending otherwise.

| Year | Detail |
|---|---|
| Current year | Monthly, and at least partly actual |
| Year 2 | Quarterly |
| Year 3 | Annual |

## Assumptions are the content

Every number traces to an assumption, written where you can find it later. Revenue is never a number, it is a count multiplied by a price.

```
Maintenance contracts at start           6
New contracts won per quarter            2       from 5 quotes at a 40% win rate
Average contract value per year   $ 84,000       current average across 6 contracts
Churn                                    1       per year, assumed, no history yet
Callout revenue                  $ 960,000       last year, held flat deliberately
Charge out rate                  $    155       break even 137 plus 12%
```

Label each one **measured** or **assumed**. In six months when actuals have diverged, this is what tells you which assumption was wrong rather than leaving you with a forecast that was simply incorrect. That is the entire value of writing them down, and it is a value a funding document never realises because nobody revisits it.

## What gets left out, and should not be

- **What the owner takes out.** Salary, drawings, or both. A plan that omits this is not modelling the business, and this is the one document where the self deception costs the most, because you are the one who acts on it
- **Sales tax and GST.** Moves through cash, is not revenue, and is the largest single payment many small businesses make
- **Payroll on costs.** Wages are not the cost of an employee. Add 20% to 35% depending on jurisdiction
- **Step costs.** The fourth tradesperson needs a fourth vehicle. Revenue rises smoothly, costs rise in steps, and a forecast where costs are a flat percentage of revenue has not modelled the business
- **Capital purchases.** A vehicle hits cash in full, the balance sheet as an asset, and profit as depreciation over years
- **Loan principal.** Cash out, and it never appears in the profit and loss

## Margin that improves needs a mechanism

The most common flaw in a forecast, and in an internal plan it is worse than embarrassing: you will run the business on it.

If gross margin rises from 38% to 44% across three years, say what causes it, in a sentence, with the split:

```
Gross margin moves 5.8 points. Roughly 3.4 come from the rate correction and
2.4 from billable percentage rising as scheduled maintenance replaces reactive
callouts. Materials margin is held flat at 20% throughout.
```

If you cannot write that sentence, the improvement is hope and the forecast should be flat.

## The downside case

One alternative, not three. Take the assumption the whole plan rests on, usually a win rate or a headcount, and halve the improvement.

Then answer the questions that matter to an owner rather than to a lender:

- **Does the business still work?** Is it still profitable, and can it still pay the owner
- **Does it run out of cash, and when?**
- **What would you do?** Decided now, calmly, rather than in the month it happens
- **What is the earliest signal** that you are on this path rather than the plan

That last one is the most valuable and the least written. If the plan needs two new contracts a quarter and quarter one delivers none, you want to have agreed in advance what that triggers.

## Presenting it

One summary table, detail in an appendix if anyone wants it.

```
| Line          |      FY26 |      FY27 |      FY28 |
|---------------|----------:|----------:|----------:|
| Revenue       | 1,498,000 | 1,927,000 | 2,344,000 |
| Gross profit  |   571,000 |   818,000 | 1,029,000 |
| Gross margin  |     38.1% |     42.5% |     43.9% |
| Overheads     |   435,000 |   560,000 |   700,000 |
| Net profit    |   136,000 |   258,000 |   329,000 |
| Owner package |   118,000 |   135,000 |   160,000 |
| Owner hours   |        58 |        48 |        36 |
```

The last two rows belong in an internal plan and appear in almost no business plan anywhere. If the owner's goal involves being paid more or working less, those are the headline numbers, and leaving them out means the plan never measures the thing it was written for.

Right align every number column, using a colon at the end of that column's delimiter row.
references/making-it-stick.md 4.9 KB
# Making it survive contact

Most business plans are executed for about six weeks. The document is not the problem. The absence of a scheduled moment where somebody looks at it again is the problem.

## Book the reviews before the plan is finished

Four dates in the calendar, ninety minutes each, before the document is signed off. Not "quarterly", which means never. Four specific dates with the people who own actions invited.

This one step does more for execution than anything in the plan itself.

## The quarterly review

Ninety minutes, same agenda every time.

```
1. Is this still where you want to go?              1 minute, first, always
2. Actuals against plan                             15 minutes
3. Each action: done, in progress, or not started   30 minutes
4. What did we learn that changes the plan          20 minutes
5. Next quarter: what, who, by when                 20 minutes
6. What are we stopping                             5 minutes
```

**Item 1 takes a minute and it is not a formality.** The owner's goal changes, and when it does half the plan may be wrong. An owner who has decided they want out in three years rather than ten needs to find that out in a review, not in year four.

**Item 3 needs the honest answer.** An action that has not started after two quarters is not behind, it is not happening. Either somebody owns it properly with time allocated, or it comes off the plan. Carrying a dead action across four reviews teaches everyone that the plan is decorative.

**Item 6 is the one that gets skipped.** Capacity comes from somewhere, and if nothing is ever stopped, nothing new is really started.

## Actuals against plan

Keep it to one table, and keep the original forecast visible. The temptation to quietly restate the plan to match what happened is strong and it destroys the only feedback loop you have.

| | Plan | Actual | Variance |
|---|---:|---:|---:|
| Revenue, quarter | | | |
| Gross margin | | | |
| Net profit | | | |
| Owner hours per week | | | |
| The one plan specific measure | | | |

That fourth row belongs in most plans and is almost never tracked. If the owner's goal involves stepping back, hours worked is a headline number, not a soft one, and it is the one most likely to be drifting the wrong way while revenue looks fine.

**Pull actuals, do not estimate them.** Where an accounting system is connected the numbers are already there, and a review that spends thirty minutes assembling figures loses the time it needed for the conversation.

## Why plans die, and what to do

| Cause | Symptom | Fix |
|---|---|---|
| Too many initiatives | Twelve started, none finished | Three to five. Park the rest visibly |
| No named owner | "Operations" owns it | A person's name |
| No review booked | Opened once, in month one | Four dates before sign off |
| Actions too big | "Improve systems" | Break to something finishable in a quarter |
| No measure | Nobody can say if it worked | One number per action, agreed at the start |
| Owner does everything | Every action has the same name on it | If the owner owns more than two, the plan is not achievable |
| Plan kept secret | Team cannot execute what they have not seen | Share the parts that affect people |
| Never updated | Plan says something the business abandoned | Change it in the review, out loud |

**The one to watch is the sixth.** In a small business the owner's name ends up on every action, which guarantees the plan competes with the day job and loses. If nobody else can own an action, that is itself the constraint and it belongs in section 4 of the plan.

## Telling the team

A plan only the owner has read cannot be executed by anyone else, and most of it is not confidential.

Share: where the business is going, what the next twelve months look like, what changes for them, and what you need from them. Keep private: the owner's personal exit and money intentions, individual pay, and anything about a specific person's performance.

**Say the same thing at each quarterly review.** A plan announced once, enthusiastically, then never mentioned, teaches people to wait out the next one.

## The annual rewrite

Once a year, properly. Not an edit.

Start again at question 1, because the answer may have moved. Then: what actually happened against last year's plan, what we got wrong and why, what the constraint is now, and the next three years from here.

**Keep the previous plans.** Reading three years of them together is the single most useful hour an owner can spend. It shows which kinds of assumption they are consistently optimistic about, which is worth more than any individual forecast.

## The minimum viable version

For an owner who will not sustain the full process, this still works and is worth far more than nothing:

- One page: where I want to be, the constraint, three things this year
- One number tracked monthly
- One hour, every quarter, in the calendar, with the page open

That is a real plan. Fifteen well written pages that nobody reopens is not.
references/rendering-the-pdf.md 5.4 KB
# Rendering the document

`scripts/render_pdf.mjs` turns the markdown into a PDF with a cover page, a contents list, running headers, page numbers and tables that do not split a row across a page.

```
node scripts/render_pdf.mjs document.md
node scripts/render_pdf.mjs document.md --out=final.pdf --brand=brand.json
node scripts/render_pdf.mjs document.md --html
```

Needs Node 18 or newer and Chrome or Edge installed. There is no `npm install`: the markdown parser and the browser client are both inside the script.

`--html` stops after writing the intermediate HTML instead of rendering. Use it whenever the pagination is wrong: open that file in a browser and press print, and you see exactly what the renderer sees. Far faster than rendering a PDF to find out where a page broke.

## The cover page

Frontmatter at the very top of the file drives it. Leave it out and there is no cover, which is right for an internal draft and wrong for anything sent outside the business.

```
---
title: Business plan
subtitle: FY26 to FY28
client: Northside Electrical
author: Dana Reyes
date: March 2026
confidential: true
---
```

Keys used: `title`, `subtitle`, `client`, `author`, `company`, `date`, `version`, `confidential`. Anything else is parsed and ignored, which makes frontmatter a safe place for notes to yourself. Without a `title` there is no cover at all.

Only `key: value` on single lines. No nesting, no lists, no multi line values.

## Markers

| Marker | Effect |
|---|---|
| `<!-- toc -->` | A contents list built from the `##` and `###` headings |
| `<!-- pagebreak -->` | Forces a new page. `\newpage` on its own line is the same |

Every other HTML comment is stripped, so author notes in a template never reach the PDF.

## The brand file

One JSON file sets colours, fonts, logo, page size and margins. Copy `assets/brand.example.json`, change the values, and every document rendered with it matches.

The logo path inside the brand file resolves relative to the brand file, not to wherever you are standing, so a brand folder moves as one piece.

**Do not edit `assets/document.css` to change a colour.** Colours and fonts are injected from the brand file as CSS variables. Editing the stylesheet is how one customer's document ends up with another customer's blue. If one document must differ, pass `--css=just-this-one.css`, which is appended after the main stylesheet so you only write the difference.

## Supported markdown

A deliberate subset: headings, paragraphs, bold, italic, strikethrough, code spans and fenced blocks, ordered and unordered lists with one level of nesting, task list checkboxes, blockquotes, horizontal rules, links, images and pipe tables.

Anything outside the subset falls through as a paragraph rather than failing, so a document never silently loses text.

**Not supported:** reference style links, inline HTML (escaped on purpose, so a document assembled partly from a customer's own words cannot inject markup), footnotes, nesting past one level, hard line breaks from trailing spaces, bare URL autolinks, and syntax highlighting in code fences.

## Table alignment, which matters most here

The delimiter row sets it, and a financial document lives or dies on this:

```
| Line          | FY26      | FY27      |
|---------------|----------:|----------:|
| Revenue       | 3,400,000 | 4,100,000 |
| Gross profit  | 1,054,000 | 1,435,000 |
```

A colon at the end of a column's delimiter right aligns it. Right aligned cells also get tabular figures, so the digits line up in a column even in a proportional font. **Right align every column of numbers.** A column of currency that is left aligned reads as amateur from across a room.

## When it comes out wrong

| Problem | Cause |
|---|---|
| "No Chrome or Edge found" | The error lists every path it tried. Add yours to the `BROWSERS` array at the top of the script |
| Images are blank boxes | Image paths resolve relative to the markdown file, logo paths relative to the brand file. A very large image can also exceed the 15 second readiness ceiling: shrink it |
| Mysterious blank page | A pagebreak immediately before a heading that already starts a page, or a trailing pagebreak at the end of the document |
| The margins doubled | An `@page` rule was added to the stylesheet. Chrome applies it on top of the print call's margins. Remove it and set margins in the brand file |
| Everything on one enormous page | Something has a fixed height larger than the page, usually a pasted element with an inline style |
| A table row split across a page | A single row taller than a page cannot avoid breaking. Shorten the cell. A long table splitting is correct, and the header repeats automatically |
| Numbers do not line up | The column is not right aligned |
| Text where markup should be | The parser is a subset. See above |

Exit codes: `0` rendered, `1` usage or input error, `2` the browser failed to start or the render failed. On `2` the intermediate HTML is deliberately left on disk and its path is printed. Open it.

## Before sending

- **Read the PDF, not the markdown.** The PDF is the artefact, and reading the source and assuming is how a broken table reaches a bank
- **Check the last page.** A single orphaned line on a final page is the most common flaw in a generated document, and the fix is usually cutting two sentences rather than changing the CSS
- **Name the file the way the recipient will file it.** `northside-electrical-business-plan-2026-03.pdf`, not `document.pdf` or `final_v3_FINAL.pdf`
references/structure.md 6.2 KB
# The sections, and how each is usually botched

## 1. Where the owner is going

**Contains:** what the owner wants their working life to look like in five to ten years, what that requires from the business, and roughly by when. One page at most.

**Botched by:** being left out. This is the section almost no business plan has, and it is the one that makes every other section coherent. Without it the plan defaults to "grow", and growth is a means that suits perhaps half of owners.

Getting a real answer is its own problem. See [where-you-are-going.md](where-you-are-going.md).

**Also botched by** writing only the money. "Worth $3m by 2033" is half an answer. The other half is involvement: full time, two days, or gone. Those produce completely different businesses at the same valuation.

## 2. Where the business is now

**Contains:** the real numbers from the ledger, what the business is genuinely good at, what is not working, how many hours the owner works and on what, and what the business depends on that would hurt if it stopped.

**Botched by:** diplomacy. There is no external reader here, so hedging costs you the only thing this section is for. The customer type that always loses money, the process that breaks every January, the person in the wrong seat: write them down.

A test: if this section is comfortable to read, it is not finished.

**The owner hours line is usually the finding.** An owner who says they want to step back in three years and is currently working 62 hours with nobody trained behind them has just described the plan.

## 3. Where it needs to be

**Contains:** the three year picture, expressed concretely enough to recognise. Revenue and profit, but also headcount, the mix of work, what the owner does day to day, and what exists that does not exist now.

**Botched by:** stating only a revenue number. "$4m by 2029" does not tell you whether the business is better or merely bigger, and bigger with the same margin and the same owner dependence is often worse.

Write it as a description of the business, then attach numbers:

```
By FY29 Meridian is a 12 person business doing mostly contracted maintenance,
running from two depots, with an operations manager handling day to day. Dale
works three days on commercial estimating. Revenue $4.1m, net margin 14%, and
no customer is more than 15% of revenue.
```

Every clause there is checkable, and several of them are not financial.

## 4. The gap and the constraint

**Contains:** the distance between sections 2 and 3, and the single thing most in the way.

**Botched by:** listing four constraints. There is usually one, and work on anything else produces nothing until it is addressed. Pick it, say why, and accept that another will take its place once it is fixed.

**Also botched by** naming a constraint the plan then ignores. If the constraint is the owner's time, and the plan is a marketing push, the plan is not going to work and everyone will be surprised.

## 5. How we close it

**Contains:** three to five initiatives. Each with what it is, why it matters for the three year picture, an owner, a date, and the number that says it worked.

**Botched by:** twelve of them. A plan with twelve initiatives is a plan where nothing is finished, and it is usually a sign that the constraint was never identified.

**Also botched by** initiatives that cannot be finished in a quarter. "Improve systems" is not an initiative, it is a category. Break it down until somebody could start on Monday and know when they were done.

The link back to section 1 has to be visible for each one. If an initiative does not serve the owner's stated goal, it needs a different justification or it needs to go.

## 6. What we stop doing

**Contains:** what comes off the plate, and what was considered and deliberately parked.

**Botched by:** not existing. This is the second most commonly missing section after section 1, and its absence is why plans fail on capacity. A small business adding five initiatives without removing anything is planning for people who do not exist.

Candidates worth examining every year: the customer segment that never quite pays, the service kept out of sentiment, the marketing channel nobody can attribute anything to, the report nobody reads, the client everyone dreads.

**Write the parked list too.** In eight months somebody will propose one of them again, and "we looked at that in March, here is why not now" saves the conversation.

## 7. The numbers

Covered in [financials.md](financials.md). Three years, assumptions visible, the owner's own drawings included, and a downside case.

**Botched by:** being built to impress. There is nobody to impress. A forecast that quietly improves margin every year with no mechanism is a forecast you will act on and be wrong about, and the cost lands on you.

## 8. This year

**Contains:** four quarters, each with what happens, who owns it, and the number that says it worked.

**Botched by:** being a restatement of section 5 with dates bolted on. This section is where the plan becomes a to do list, and it should read like one.

**Also botched by** the owner's name against every action. In a small business this happens by default and it guarantees the plan competes with the day job. If nobody else can own an action, that is a finding for section 4.

## 9. What could derail it

**Contains:** four to six specific risks, each with what you would do and what warning you would get.

**Botched by:** generic entries with generic responses. "Competition: monitor the market" is worse than nothing because it demonstrates the exercise was performed rather than thought about.

For an internal plan the useful risks are frequently uncomfortable and personal: the key person is the owner's brother, the largest customer is a friendship, the second in charge is not actually capable of the role. External readers make these hard to write. There is no external reader here.

## 10. How this gets reviewed

**Contains:** four dates, who attends, and the standing agenda.

**Botched by:** saying "quarterly" without dates. Detail in [making-it-stick.md](making-it-stick.md).

This section takes two minutes to write and it is the difference between a plan that changes the business and a document in a folder. Put it in before the plan is signed off, because afterwards nobody does.
references/where-you-are-going.md 5.0 KB
# Getting a real answer to question 1

The whole plan hangs off what the owner actually wants, and it is the question they are least practised at answering. Expect deflection, and expect it to be sincere rather than evasive: most owners have genuinely never been asked.

## The deflections, and what to do with each

| What they say | What is going on | Try |
|---|---|---|
| "I just want it to keep growing" | Growth as a default, not a goal | "Growing to what? What does it let you do that you cannot do now?" |
| "I have not really thought about it" | True, and they need permission to | "That is normal. Try it as a Tuesday: what are you doing in five years, on a Tuesday?" |
| "Whatever is best for the business" | Treating themselves as a servant of the business | "The business exists to serve you. If it cannot, that is the finding" |
| "I want to sell eventually" | Vague, and it hides the real question | "For how much, and to whom? And what happens the day after?" |
| "Retire, I suppose" | Often means "escape", not retire | "If the business paid you the same and took two days a week, would you still want out?" |
| "More money" | A means | "How much, and what does it change? Most owners say a number then describe a life" |

**The Tuesday question is the one that works most reliably.** "It is a Tuesday in five years. What time do you get up, where do you go, what do you do?" People who cannot answer a strategy question answer that one easily, and the answer contains everything: hours, involvement, the kind of work, whether they are still on the tools.

## What a usable answer contains

Four things. Push until you have them, and write them down in the owner's words.

1. **Involvement.** Full time, part time, stepped back, out entirely
2. **Money.** What the business pays them, or what it is worth on exit
3. **A rough date.** "Five to ten years" is fine. "Eventually" is not
4. **What they want to still be doing**, because most owners like some part of the work and a plan that removes all of it fails quietly

```
By 2033 I want to be working two days a week on the commercial side only, with
someone else running day to day. I want the business to pay me $220,000 whether
or not I am there. I do not want to sell, but I want it to be sellable. I want
to keep doing the design and quoting work because I like it.
```

That last sentence is not decoration. It rules out a plan that hires an estimator and leaves the owner doing admin they hate.

## Translate it into requirements

This is the step that turns a personal answer into a business plan. Do it explicitly, in the document, so the link is visible later.

| What the owner said | What the business has to become |
|---|---|
| Two days a week | Someone else running operations. A real second in charge, hired and trained before the owner steps back, not after |
| Pays me $220,000 regardless | Profit that does not depend on the owner's billable hours. Margin and mix, not just volume |
| Sellable even if I do not sell | Documented systems, no customer over 20% of revenue, clean books, contracted revenue |
| Keep doing design and quoting | The GM hire covers operations, not the technical work. Do not hire the wrong role |

**Where two requirements conflict, surface it now.** "I want to step back in three years" and "I want to double revenue in three years" are frequently in tension, because the growth needs the owner. Naming the tension is more useful than a plan that quietly assumes both.

## Partners and family

If there is a co-owner, ask both separately, then compare. Diverging answers are common and are usually discovered years later during an argument about something else.

The classic split: one wants to sell in five years, the other wants to hand it to a child. Almost every downstream decision looks different depending on which is true, and a plan written without settling it will be executed against by one of them.

If the answer involves family, question 10 matters: does the family member know, and do they want it. A succession plan the successor has not agreed to is not a plan.

## Where the owner genuinely does not know

Legitimate, and common at certain moments: a recent illness, a business that has just changed shape, someone five years into something they fell into.

Do not fabricate an answer to make the document tidy. Write the plan against a three year horizon, note explicitly that the longer view is unresolved, and make it the first item at the next review.

A useful interim: **"what would you want to be true in three years regardless of which direction you pick?"** Usually the answer is less owner dependence, better margin and cleaner books, and those are worth doing under every scenario. That is a real plan and an honest one.

## Revisit it

At every quarterly review, first item, one minute: is this still what you want.

It changes, and when it changes the plan should change with it, sometimes a great deal. An owner who decides they want out in three years rather than ten has just made half the plan wrong, and the sooner that surfaces the less is wasted.
scripts/render_pdf.mjs 20.4 KB
/**
 * Render a markdown document to a branded, print-quality PDF.
 *
 *   node render_pdf.mjs plan.md
 *   node render_pdf.mjs plan.md --out=business-plan.pdf --brand=brand.json
 *   node render_pdf.mjs plan.md --html            keep the intermediate HTML and stop
 *
 * Needs Node 18+ and Chrome or Edge installed. No npm install: the markdown parser
 * and the CDP client below are the whole dependency list.
 *
 * Why Chrome and not a PDF library: a real business document needs a cover page,
 * running headers and footers, page numbers, controlled page breaks and tables that
 * do not split a row across a page. Chrome's print engine does all of that from CSS
 * you can read, and the output is the same shape a designer would expect. A PDF
 * library would mean drawing every box by hand.
 *
 * The intermediate HTML is written NEXT TO the source markdown, not in a temp
 * directory, so relative image paths in the markdown resolve the way the author
 * wrote them. It is deleted afterwards unless --html is passed.
 *
 * Frontmatter (optional, must be the very first thing in the file) drives the cover:
 *
 *     ---
 *     title: Business plan
 *     subtitle: FY26 to FY28
 *     client: Northside Electrical
 *     author: Dana Reyes
 *     date: March 2026
 *     confidential: true
 *     ---
 *
 * Body markers:
 *     <!-- toc -->        replaced by a contents list built from the h2 and h3 headings
 *     <!-- pagebreak -->  forces a new page. \newpage on its own line does the same
 *
 * Exit codes: 0 rendered, 1 usage or input error, 2 browser or render failure.
 */

import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, extname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

const HERE = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const srcArg = argv.find((a) => !a.startsWith("--"));
const flag = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
const htmlOnly = argv.includes("--html");

if (!srcArg) {
  console.error("Usage: node render_pdf.mjs <document.md> [--out=file.pdf] [--brand=brand.json] [--css=extra.css] [--html]");
  process.exit(1);
}

const srcPath = resolve(srcArg);
if (!existsSync(srcPath)) {
  console.error(`No such file: ${srcPath}`);
  process.exit(1);
}
const srcDir = dirname(srcPath);
const outPath = resolve(flag("out") ?? join(srcDir, `${basename(srcPath, extname(srcPath))}.pdf`));

const BROWSERS = [
  "C:/Program Files/Microsoft/Edge/Application/msedge.exe",
  "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
  "C:/Program Files/Google/Chrome/Application/chrome.exe",
  "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
  "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
  "/usr/bin/google-chrome",
  "/usr/bin/chromium",
  "/usr/bin/chromium-browser",
];

const DEFAULT_BRAND = {
  name: "",
  accent: "#0B6EE8",
  ink: "#12161C",
  muted: "#5A6472",
  rule: "#DCE1E8",
  headingFont: "Georgia, 'Times New Roman', serif",
  bodyFont: "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif",
  monoFont: "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
  logo: null,
  footerNote: "",
  pageSize: "A4",
  margin: { top: "22mm", bottom: "20mm", left: "18mm", right: "18mm" },
};

/* ------------------------------------------------------------------ markdown */

const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

/** Inline spans. Code first, so a backtick span is never re-parsed for emphasis. */
function inline(text) {
  const code = [];
  let s = text.replace(/`([^`]+)`/g, (_, c) => `\u0000${code.push(`<code>${esc(c)}</code>`) - 1}\u0000`);
  // Author notes are not content. Code spans were lifted out above, so a comment
  // deliberately being SHOWN inside backticks survives this.
  s = s.replace(/<!--[\s\S]*?-->/g, "");
  s = esc(s);
  s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g,
    (_, alt, src, title) => `<img src="${src}" alt="${alt}"${title ? ` title="${title}"` : ""}>`);
  s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>');
  s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
  s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>");
  s = s.replace(/~~([^~]+)~~/g, "<del>$1</del>");
  s = s.replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
  return s;
}

const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");

/** Pull `key: value` frontmatter if the file opens with a --- fence. */
function frontmatter(raw) {
  const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
  if (!m) return { meta: {}, body: raw };
  const meta = {};
  for (const line of m[1].split(/\r?\n/)) {
    const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
    if (kv) meta[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, "");
  }
  return { meta, body: raw.slice(m[0].length) };
}

/**
 * Block parser. A deliberate subset: headings, paragraphs, fenced code, pipe tables,
 * ordered and unordered lists with one level of nesting, blockquotes, rules, images.
 * Anything outside the subset passes through as a paragraph rather than failing, so a
 * document never silently loses text.
 */
function blocks(md) {
  const lines = md.replace(/\r\n/g, "\n").split("\n");
  const out = [];
  const headings = [];
  let i = 0;

  /** A sublist belongs INSIDE the <li> it hangs off, not after it. */
  const nest = (item, sub) => item.replace(/<\/li>$/, `<ul>${sub.join("")}</ul></li>`);

  const listItem = (text) => {
    const task = text.match(/^\[([ xX])\]\s+(.*)$/);
    if (!task) return `<li>${inline(text)}</li>`;
    const done = task[1].toLowerCase() === "x";
    return `<li class="task"><span class="box${done ? " on" : ""}"></span>${inline(task[2])}</li>`;
  };

  while (i < lines.length) {
    const line = lines[i];

    if (!line.trim()) { i++; continue; }

    if (/^(<!--\s*pagebreak\s*-->|\\newpage)\s*$/i.test(line.trim())) {
      out.push('<div class="pagebreak"></div>'); i++; continue;
    }
    if (/^<!--\s*toc\s*-->$/i.test(line.trim())) { out.push("\u0001TOC\u0001"); i++; continue; }

    // An author note, possibly spanning several lines. Templates are full of these
    // and none of them belong in the rendered document.
    if (line.trimStart().startsWith("<!--")) {
      while (i < lines.length && !lines[i].includes("-->")) i++;
      i++;
      continue;
    }

    const h = line.match(/^(#{1,6})\s+(.*)$/);
    if (h) {
      const level = h[1].length;
      const text = h[2].trim();
      const id = slug(text);
      if (level === 2 || level === 3) headings.push({ level, text, id });
      out.push(`<h${level} id="${id}">${inline(text)}</h${level}>`);
      i++; continue;
    }

    const fence = line.match(/^```\s*([\w-]*)\s*$/);
    if (fence) {
      const buf = [];
      i++;
      while (i < lines.length && !/^```\s*$/.test(lines[i])) buf.push(lines[i++]);
      i++;
      out.push(`<pre class="code"><code>${esc(buf.join("\n"))}</code></pre>`);
      continue;
    }

    if (/^(\*\*\*|---|___)\s*$/.test(line.trim())) { out.push("<hr>"); i++; continue; }

    // Pipe table. Needs the delimiter row, otherwise it is just text with pipes in it.
    if (line.includes("|") && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1] ?? "")) {
      const cells = (r) => r.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
      const head = cells(line);
      const align = cells(lines[i + 1]).map((d) =>
        d.startsWith(":") && d.endsWith(":") ? "center" : d.endsWith(":") ? "right" : "left");
      i += 2;
      const body = [];
      while (i < lines.length && lines[i].includes("|") && lines[i].trim()) body.push(cells(lines[i++]));
      const th = head.map((c, n) => `<th style="text-align:${align[n] ?? "left"}">${inline(c)}</th>`).join("");
      const tr = body.map((r) =>
        `<tr>${r.map((c, n) => `<td style="text-align:${align[n] ?? "left"}">${inline(c)}</td>`).join("")}</tr>`).join("");
      out.push(`<table><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table>`);
      continue;
    }

    if (/^\s*>/.test(line)) {
      const buf = [];
      while (i < lines.length && /^\s*>/.test(lines[i])) buf.push(lines[i++].replace(/^\s*>\s?/, ""));
      out.push(`<blockquote>${blocks(buf.join("\n")).html}</blockquote>`);
      continue;
    }

    const bullet = line.match(/^(\s*)([-*+])\s+(.*)$/);
    const number = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/);
    if (bullet || number) {
      const ordered = Boolean(number);
      const tag = ordered ? "ol" : "ul";
      const start = ordered ? Number(number[2]) : 1;
      const items = [];
      let nested = null;
      while (i < lines.length) {
        const b = lines[i].match(/^(\s*)([-*+])\s+(.*)$/);
        const n = lines[i].match(/^(\s*)(\d+)[.)]\s+(.*)$/);
        const m = b || n;
        if (!m) {
          // A plain indented line continues the item it follows.
          if (items.length && /^\s{2,}\S/.test(lines[i])) { items[items.length - 1] += ` ${inline(lines[i].trim())}`; i++; continue; }
          break;
        }
        if (Boolean(n) !== ordered && m[1].length === 0) break;
        if (m[1].length >= 2) {
          nested ??= [];
          nested.push(listItem(m[3]));
          i++; continue;
        }
        if (nested) { items[items.length - 1] = nest(items[items.length - 1], nested); nested = null; }
        items.push(listItem(m[3]));
        i++;
      }
      if (nested && items.length) items[items.length - 1] = nest(items[items.length - 1], nested);
      out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ""}>${items.join("")}</${tag}>`);
      continue;
    }

    const para = [];
    while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|```|\s*>|\s*[-*+]\s|\s*\d+[.)]\s)/.test(lines[i])) {
      para.push(lines[i++]);
    }
    const joined = para.join(" ").trim();
    if (!joined) { i++; continue; }
    // A paragraph that is nothing but an image gets to be a figure, not a text line.
    const lone = joined.match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/);
    out.push(lone
      ? `<figure><img src="${lone[2]}" alt="${lone[1]}">${lone[1] ? `<figcaption>${inline(lone[1])}</figcaption>` : ""}</figure>`
      : `<p>${inline(joined)}</p>`);
  }

  return { html: out.join("\n"), headings };
}

/* ---------------------------------------------------------------- assembling */

function coverPage(meta, brand) {
  if (!meta.title) return "";
  const logo = brand.logo ? `<img class="cover-logo" src="${brand.logo}" alt="">` : "";
  const rows = [
    meta.client && ["Prepared for", meta.client],
    meta.author && ["Prepared by", meta.author],
    (meta.company || brand.name) && ["Company", meta.company || brand.name],
    meta.date && ["Date", meta.date],
    meta.version && ["Version", meta.version],
  ].filter(Boolean);
  return `<section class="cover">
  ${logo}
  <div class="cover-body">
    <h1 class="cover-title">${inline(meta.title)}</h1>
    ${meta.subtitle ? `<p class="cover-subtitle">${inline(meta.subtitle)}</p>` : ""}
    ${rows.length ? `<dl class="cover-meta">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${inline(v)}</dd>`).join("")}</dl>` : ""}
    ${String(meta.confidential).toLowerCase() === "true" ? '<p class="cover-confidential">Commercial in confidence</p>' : ""}
  </div>
</section>
<div class="pagebreak"></div>`;
}

/**
 * A contents list, without page numbers. Page numbers would need a second render to
 * find out what page each heading landed on, and a contents list that is confidently
 * wrong is worse than one that is honestly a list of sections.
 */
function toc(headings) {
  if (!headings.length) return "";
  const items = headings.map((h) =>
    `<li class="toc-h${h.level}"><a href="#${h.id}">${inline(h.text)}</a></li>`).join("");
  return `<nav class="toc"><h2 class="toc-title">Contents</h2><ol>${items}</ol></nav>`;
}

async function loadBrand() {
  const path = flag("brand");
  if (!path) return DEFAULT_BRAND;
  let parsed;
  try {
    parsed = JSON.parse(await readFile(resolve(path), "utf8"));
  } catch (e) {
    console.error(`Could not read brand file ${path}: ${e.message}`);
    process.exit(1);
  }
  const brand = { ...DEFAULT_BRAND, ...parsed, margin: { ...DEFAULT_BRAND.margin, ...(parsed.margin ?? {}) } };
  // A logo path in the brand file is relative to the brand file, not the cwd.
  if (brand.logo && !/^(https?:|data:)/.test(brand.logo)) {
    brand.logo = pathToFileURL(resolve(dirname(resolve(path)), brand.logo)).href;
  }
  return brand;
}

async function readCss(brand) {
  const bundled = join(HERE, "..", "assets", "document.css");
  let css = existsSync(bundled) ? await readFile(bundled, "utf8") : "";
  if (!css) console.warn("assets/document.css not found next to the script. Rendering with browser defaults.");
  const extra = flag("css");
  if (extra) css += `\n${await readFile(resolve(extra), "utf8")}`;
  const vars = `:root{
  --accent:${brand.accent};--ink:${brand.ink};--muted:${brand.muted};--rule:${brand.rule};
  --font-heading:${brand.headingFont};--font-body:${brand.bodyFont};--font-mono:${brand.monoFont};
}`;
  return `${vars}\n${css}`;
}

/* ------------------------------------------------------------------- browser */

class Cdp {
  #ws; #id = 0; #pending = new Map();
  static async connect(url) {
    const c = new Cdp();
    c.#ws = new WebSocket(url);
    await new Promise((res, rej) => {
      c.#ws.onopen = res;
      c.#ws.onerror = () => rej(new Error("CDP websocket failed to open"));
    });
    c.#ws.onmessage = (ev) => {
      const msg = JSON.parse(ev.data);
      const p = c.#pending.get(msg.id);
      if (!p) return;                         // an event, not a reply
      c.#pending.delete(msg.id);
      msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result);
    };
    return c;
  }
  send(method, params = {}, sessionId) {
    const id = ++this.#id;
    return new Promise((resolve, reject) => {
      this.#pending.set(id, { resolve, reject });
      this.#ws.send(JSON.stringify({ id, method, params, sessionId }));
    });
  }
  close() { try { this.#ws.close(); } catch { /* already gone */ } }
}

async function launch(port) {
  const exe = BROWSERS.find((p) => existsSync(p));
  if (!exe) throw new Error(`No Chrome or Edge found. Looked in:\n  ${BROWSERS.join("\n  ")}`);
  const profile = await mkdtemp(join(tmpdir(), "render-pdf-"));
  const proc = spawn(exe, [
    "--headless=new", `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`,
    "--no-first-run", "--no-default-browser-check", "--disable-extensions",
    "--force-color-profile=srgb", "--allow-file-access-from-files", "about:blank",
  ]);
  proc.on("error", (e) => { console.error("Failed to launch browser:", e.message); process.exit(2); });

  const deadline = Date.now() + 20000;
  let wsUrl;
  while (Date.now() < deadline && !wsUrl) {
    try {
      const res = await fetch(`http://127.0.0.1:${port}/json/version`);
      if (res.ok) wsUrl = (await res.json()).webSocketDebuggerUrl;
    } catch { /* not up yet */ }
    if (!wsUrl) await new Promise((r) => setTimeout(r, 120));
  }
  if (!wsUrl) throw new Error(`DevTools never came up on :${port}`);
  const cdp = await Cdp.connect(wsUrl);
  return {
    cdp,
    async dispose() { cdp.close(); proc.kill(); await rm(profile, { recursive: true, force: true }).catch(() => {}); },
  };
}

const PAGE_SIZES = {          // inches, because printToPDF takes inches
  A4: [8.27, 11.69],
  Letter: [8.5, 11],
  Legal: [8.5, 14],
  A5: [5.83, 8.27],
};

const mmToIn = (v) => {
  const n = parseFloat(v);
  if (/mm$/i.test(v)) return n / 25.4;
  if (/cm$/i.test(v)) return n / 2.54;
  if (/in$/i.test(v)) return n;
  if (/pt$/i.test(v)) return n / 72;
  return n / 25.4;            // bare numbers are millimetres
};

/* ---------------------------------------------------------------------- main */

const raw = await readFile(srcPath, "utf8");
const { meta, body } = frontmatter(raw);
const brand = await loadBrand();
const parsed = blocks(body);
const html = parsed.html.replace("\u0001TOC\u0001", toc(parsed.headings));
const css = await readCss(brand);

const docTitle = meta.title || basename(srcPath, extname(srcPath));
const page = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${esc(docTitle)}</title>
<style>${css}</style></head>
<body class="doc">
${coverPage(meta, brand)}
<main>${html}</main>
</body></html>`;

const htmlPath = join(srcDir, `.${basename(srcPath, extname(srcPath))}.render.html`);
await writeFile(htmlPath, page, "utf8");

if (htmlOnly) {
  console.log(`HTML written to ${htmlPath}`);
  console.log("Open it in a browser and use Print to preview pagination before rendering the PDF.");
  process.exit(0);
}

const [pw, ph] = PAGE_SIZES[brand.pageSize] ?? PAGE_SIZES.A4;
// The header and footer are separate mini-documents with no access to the page CSS,
// so their styling is inline and their font sizes are absolute. This is a Chrome rule,
// not a choice.
const chrome = (content) =>
  `<div style="width:100%;font-size:8px;font-family:${brand.bodyFont.replace(/"/g, "'")};color:${brand.muted};
   padding:0 ${brand.margin.left} 0 ${brand.margin.right};display:flex;justify-content:space-between;">${content}</div>`;

let browser;
try {
  browser = await launch(9339);
  const { cdp } = browser;
  const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
  const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
  await cdp.send("Page.enable", {}, sessionId);
  await cdp.send("Runtime.enable", {}, sessionId);
  await cdp.send("Page.navigate", { url: pathToFileURL(htmlPath).href }, sessionId);

  // The Cdp client above drops events on the floor, so readiness is polled rather than
  // awaited. Poll for the real condition: the document is complete AND every image has
  // finished, because a half decoded image prints as a blank box with no error anywhere.
  // `complete` goes true when an image finishes loading OR fails. Waiting for
  // naturalWidth as well would hang the full 15s on every broken path, which is the
  // opposite of useful: a missing logo should be reported instantly, not waited on.
  const ready = `document.readyState === "complete" &&
    Array.from(document.images).every((i) => i.complete)`;
  const deadline = Date.now() + 15000;
  for (;;) {
    const { result } = await cdp.send("Runtime.evaluate", { expression: ready, returnByValue: true }, sessionId);
    if (result.value === true) break;
    if (Date.now() > deadline) { console.warn("Page never settled after 15s. Printing it as it stands."); break; }
    await new Promise((r) => setTimeout(r, 100));
  }

  // Name anything that failed to load. A blank box in a PDF with no warning anywhere
  // is the single most common way a branded document goes out looking broken.
  const { result: broken } = await cdp.send("Runtime.evaluate", {
    expression: `JSON.stringify(Array.from(document.images)
      .filter((i) => i.naturalWidth === 0 && i.getAttribute("src"))
      .map((i) => i.getAttribute("src")))`,
    returnByValue: true,
  }, sessionId);
  for (const src of JSON.parse(broken.value || "[]")) {
    console.warn(`Image did not load, it will be blank in the PDF: ${decodeURI(src)}`);
  }
  // Fonts lay out after load and shift the pagination if printed too early.
  await cdp.send("Runtime.evaluate", { expression: "document.fonts.ready", awaitPromise: true }, sessionId).catch(() => {});

  const { data } = await cdp.send("Page.printToPDF", {
    printBackground: true,
    preferCSSPageSize: false,
    paperWidth: pw,
    paperHeight: ph,
    marginTop: mmToIn(brand.margin.top),
    marginBottom: mmToIn(brand.margin.bottom),
    marginLeft: mmToIn(brand.margin.left),
    marginRight: mmToIn(brand.margin.right),
    displayHeaderFooter: true,
    headerTemplate: chrome(`<span>${esc(brand.name || "")}</span><span>${esc(docTitle)}</span>`),
    footerTemplate: chrome(
      `<span>${esc(brand.footerNote || "")}</span><span class="pageNumber"></span>`),
  }, sessionId);

  await writeFile(outPath, Buffer.from(data, "base64"));
  console.log(`Wrote ${outPath}`);
  if (parsed.headings.length) console.log(`${parsed.headings.length} headings, cover ${meta.title ? "on" : "off"}`);
} catch (e) {
  console.error(`Render failed: ${e.message}`);
  console.error(`The intermediate HTML is at ${htmlPath}. Open it in a browser to see what the page actually looks like.`);
  process.exitCode = 2;
} finally {
  await browser?.dispose();
  if (!process.exitCode) await rm(htmlPath, { force: true }).catch(() => {});
}
assets/brand.example.json 621 B
{
  "name": "Northside Electrical",
  "accent": "#0B6EE8",
  "ink": "#12161C",
  "muted": "#5A6472",
  "rule": "#DCE1E8",

  "headingFont": "Georgia, 'Times New Roman', serif",
  "bodyFont": "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif",
  "monoFont": "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",

  "_logo": "a path relative to THIS file, e.g. logo.png. Rename to \"logo\" to use it",
  "logo": null,
  "footerNote": "Northside Electrical Pty Ltd  |  Commercial in confidence",

  "pageSize": "A4",
  "margin": { "top": "22mm", "bottom": "20mm", "left": "18mm", "right": "18mm" }
}
assets/business-plan-template.md 6.5 KB
---
title: Business plan
subtitle: <FY26 to FY29>
company: <Business name>
author: <Who wrote it>
date: <Month Year>
confidential: true
---

<!-- An INTERNAL planning document. Written for the owner and the people who have to
     execute it, not for a bank or an investor. If you need a funding pack, that is a
     separate document with a different audience, and trying to make one document do
     both makes it dishonest in exactly the places where honesty is the point.

     Render this to PDF with the script that ships alongside this template:
       node scripts/render_pdf.mjs business-plan.md --brand=brand.json

     Delete every angle bracket prompt as you go. -->

<!-- toc -->

## 1. Where I am going

<The point of this whole document. What do you want your working life to look like
in five to ten years? Involvement, money, a rough date, and what you want to still
be doing. In your own words, one page at most.>

<By 2033 I want to be working two days a week on the commercial side only, with
someone else running day to day. I want the business to pay me $220,000 whether or
not I am there. I do not want to sell, but I want it to be sellable. I want to keep
doing the design and quoting work because I like it.>

**What that requires from the business**

| What I said | What the business has to become |
|---|---|
| <Two days a week> | <Someone else running operations. Hired and trained before I step back> |
| <Pays me $X regardless> | <Profit not dependent on my billable hours> |
| <Sellable> | <Documented systems, no customer over 20%, clean books> |
| <Keep doing X> | <The hire covers operations, not the technical work> |

<Where two of these conflict, say so here. "Step back in three years" and "double
revenue in three years" are frequently in tension, and naming it beats a plan that
quietly assumes both.>

## 2. Where the business is now

<Written bluntly. There is no external reader. If this section is comfortable to
read, it is not finished.>

| | |
|---|---:|
| Revenue, last full year | <$> |
| Gross margin | <%> |
| Net profit | <$> |
| Owner package, salary plus drawings | <$> |
| **Hours I actually work per week** | <> |
| People | <> |
| Largest customer, share of revenue | <%> |

**What we are genuinely good at**

- <Specific enough to build on>

**What is not working**

- <The customer type that always loses money>
- <The process that breaks every January>
- <The person who is not in the right seat>

**What we depend on that would hurt if it stopped**

| Dependency | Exposure |
|---|---|
| <One customer, supplier, person, referral source> | <> |

## 3. Where it needs to be in three years

<Write it as a description of the business first, then attach numbers. A revenue
target alone does not tell you whether the business is better or merely bigger.>

<By FY29 we are a 12 person business doing mostly contracted maintenance, running
from two depots, with an operations manager handling day to day. I work three days
on commercial estimating.>

| | Now | FY29 |
|---|---:|---:|
| Revenue | | |
| Net margin | | |
| People | | |
| <Key mix measure> | | |
| Owner hours per week | | |
| Owner package | | |
| Largest customer share | | |

## 4. The gap, and the one constraint

<What is actually in the way. Pick ONE. A plan addressing four constraints
addresses none.>

**The constraint is:** <owner's time / cash / people / demand / margin / systems>

<Why, in three or four lines. What evidence says so.>

<What this rules out. If the constraint is your own time, a marketing push is not
the plan.>

## 5. How we close it

<Three to five. Not twelve. Each has to serve section 1, and the link should be
visible.>

### <Initiative 1>

| | |
|---|---|
| What | <> |
| Why it matters for the three year picture | <> |
| Owner | <A person> |
| Done by | <> |
| Measure | <One number, tracked> |

### <Initiative 2>

| | |
|---|---|
| What | <> |
| Why | <> |
| Owner | <> |
| Done by | <> |
| Measure | <> |

## 6. What we stop doing

<Capacity comes from somewhere. A plan that only adds is a wish list.>

| Stopping | Why | Frees up |
|---|---|---|
| <> | <> | <> |

**Considered and parked**

<In eight months somebody will suggest one of these again. This paragraph saves
that conversation.>

| Parked | Why not now | Revisit |
|---|---|---|
| <> | <> | <> |

## 7. The numbers

### Assumptions

<Label each one measured or assumed. This is what tells you WHICH assumption was
wrong when actuals diverge, which is the whole reason to write them down.>

| Assumption | Value | Measured or assumed |
|---|---:|---|
| <> | | |

### Three years

| Line | <FY27> | <FY28> | <FY29> |
|---|---:|---:|---:|
| Revenue | | | |
| Gross profit | | | |
| Gross margin | <%> | <%> | <%> |
| Overheads | | | |
| **Net profit** | | | |
| **Owner package** | | | |
| **Owner hours per week** | | | |

<The last two rows appear in almost no business plan anywhere. If your goal
involves being paid more or working less, they are the headline numbers, and
leaving them out means the plan never measures the thing it was written for.>

**On any margin improvement.** <Say what causes it, with the split. If you cannot
write that sentence, hold the margin flat.>

### Downside case

<Halve the improvement on the assumption the whole plan rests on.>

| | Plan | Downside |
|---|---:|---:|
| Revenue FY29 | | |
| Net profit FY29 | | |
| Cash low point | | |

- **Does the business still work?** <>
- **What would we do?** <Decided now, not in the month it happens>
- **Earliest signal we are on this path:** <>

## 8. This year

<Where the plan becomes a to do list. If your name is against every action, the
plan competes with your day job and loses, and that is itself a finding for
section 4.>

| Quarter | What happens | Owner | Measure |
|---|---|---|---|
| Q1 | | | |
| Q2 | | | |
| Q3 | | | |
| Q4 | | | |

## 9. What could derail this

<Four to six real ones. For an internal plan the useful risks are often
uncomfortable and personal. There is no external reader.>

| Risk | What we would do | Warning sign |
|---|---|---|
| <> | <> | <> |

## 10. How this gets reviewed

<Put real dates in before signing this off. Afterwards nobody does, and "quarterly"
without dates means never.>

| Review | Date | Who |
|---|---|---|
| Q1 | | |
| Q2 | | |
| Q3 | | |
| Q4 | | |
| Annual rewrite | | |

**Standing agenda:** is this still where I want to go (1 min) | actuals against plan | each action: done, in progress, not started | what we learned that changes the plan | next quarter | what we are stopping.

**What the team is told:** <which parts get shared, and when>
assets/document.css 5.7 KB
/*
 * Print stylesheet for render_pdf.mjs.
 *
 * The script injects a :root block above this file with --accent, --ink, --muted,
 * --rule and the three font stacks, taken from the brand file. Everything here is
 * expressed in those variables, so rebranding a document means editing brand.json
 * and not this file.
 *
 * Page size and margins are set by the script through printToPDF, not by @page.
 * Chrome applies printToPDF margins and @page margins to the same box, and having
 * both set is the usual cause of a document whose margins mysteriously double.
 */

* { box-sizing: border-box; }

body.doc {
  margin: 0;
  font-family: var(--font-body);
  font-size: 10.5pt;
  line-height: 1.55;
  color: var(--ink);
  -webkit-print-color-adjust: exact;
  print-color-adjust: exact;
}

/* ------------------------------------------------------------------- cover */

.cover {
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* Fills the printable box. 297mm less the 22mm and 20mm the script sets. */
  min-height: 250mm;
}

.cover-logo { max-height: 18mm; max-width: 70mm; margin-bottom: 16mm; }

.cover-title {
  font-family: var(--font-heading);
  font-size: 34pt;
  line-height: 1.12;
  font-weight: 700;
  margin: 0 0 4mm;
  color: var(--ink);
}

.cover-subtitle {
  font-size: 13pt;
  color: var(--muted);
  margin: 0 0 14mm;
  font-weight: 400;
}

.cover-body::before {
  content: "";
  display: block;
  width: 28mm;
  height: 3pt;
  background: var(--accent);
  margin-bottom: 10mm;
}

.cover-meta {
  display: grid;
  grid-template-columns: 34mm 1fr;
  gap: 2mm 6mm;
  margin: 0;
  font-size: 10pt;
}
.cover-meta dt { color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; font-size: 8pt; padding-top: 1pt; }
.cover-meta dd { margin: 0; font-weight: 600; }

.cover-confidential {
  margin-top: 16mm;
  font-size: 8pt;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
}

/* --------------------------------------------------------------- headings */

h1, h2, h3, h4, h5, h6 {
  font-family: var(--font-heading);
  color: var(--ink);
  line-height: 1.25;
  /* Never leave a heading alone at the foot of a page. */
  break-after: avoid-page;
  page-break-after: avoid;
  break-inside: avoid-page;
}

main h1 { font-size: 22pt; margin: 0 0 6mm; }
h2 {
  font-size: 15pt;
  margin: 10mm 0 3mm;
  padding-bottom: 2mm;
  border-bottom: 0.7pt solid var(--rule);
}
h3 { font-size: 12pt; margin: 7mm 0 2mm; }
h4 { font-size: 10.5pt; margin: 5mm 0 1.5mm; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }

main > h2:first-child, main > h1:first-child { margin-top: 0; }

/* ------------------------------------------------------------------- text */

p { margin: 0 0 3.2mm; orphans: 3; widows: 3; }

a { color: var(--accent); text-decoration: none; }

strong { font-weight: 650; }

ul, ol { margin: 0 0 3.2mm; padding-left: 6mm; }
li { margin-bottom: 1.4mm; break-inside: avoid; }
li > ul, li > ol { margin-top: 1.4mm; }

li.task { list-style: none; margin-left: -5mm; display: flex; gap: 2.5mm; align-items: baseline; }
li.task .box {
  flex: 0 0 auto;
  width: 3mm; height: 3mm;
  border: 0.7pt solid var(--muted);
  border-radius: 0.6mm;
}
li.task .box.on { background: var(--accent); border-color: var(--accent); }

blockquote {
  margin: 0 0 3.2mm;
  padding: 1mm 0 1mm 5mm;
  border-left: 2pt solid var(--accent);
  color: var(--muted);
  break-inside: avoid;
}
blockquote p:last-child { margin-bottom: 0; }

hr { border: 0; border-top: 0.7pt solid var(--rule); margin: 7mm 0; }

code {
  font-family: var(--font-mono);
  font-size: 0.88em;
  background: #F3F5F8;
  padding: 0.3mm 1mm;
  border-radius: 0.8mm;
}

pre.code {
  font-family: var(--font-mono);
  font-size: 8.5pt;
  line-height: 1.45;
  background: #F3F5F8;
  border: 0.7pt solid var(--rule);
  border-radius: 1.5mm;
  padding: 3mm 4mm;
  margin: 0 0 3.2mm;
  white-space: pre-wrap;      /* a long line wraps rather than being cut off the page */
  word-break: break-word;
  break-inside: avoid;
}
pre.code code { background: none; padding: 0; font-size: inherit; }

/* ----------------------------------------------------------------- tables */

table {
  width: 100%;
  border-collapse: collapse;
  margin: 0 0 4mm;
  font-size: 9.5pt;
  break-inside: auto;
}
thead { display: table-header-group; }   /* repeat the header on every page */
tr { break-inside: avoid; page-break-inside: avoid; }
th {
  text-align: left;
  font-weight: 650;
  font-size: 8pt;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--muted);
  border-bottom: 1pt solid var(--ink);
  padding: 2mm 2.5mm;
}
td { padding: 2mm 2.5mm; border-bottom: 0.5pt solid var(--rule); vertical-align: top; }
tbody tr:nth-child(even) { background: #FAFBFC; }

/* Right align a column of numbers by putting :--- in the markdown delimiter row. */
td[style*="right"], th[style*="right"] { font-variant-numeric: tabular-nums; }

/* ---------------------------------------------------------------- figures */

figure { margin: 0 0 4mm; break-inside: avoid; text-align: center; }
img { max-width: 100%; }
figcaption { font-size: 8.5pt; color: var(--muted); margin-top: 1.5mm; }

/* -------------------------------------------------------------- contents */

.toc { break-after: page; page-break-after: always; }
.toc-title { border: 0; margin-top: 0; }
.toc ol { list-style: none; padding: 0; }
.toc li { margin-bottom: 2mm; }
.toc a { color: var(--ink); }
.toc-h3 { padding-left: 6mm; font-size: 9.5pt; color: var(--muted); }
.toc-h3 a { color: var(--muted); }

/* ----------------------------------------------------------------- breaks */

.pagebreak { break-after: page; page-break-after: always; height: 0; }

/* Put class="keep" on a heading to start its section on a fresh page. */
.keep { break-before: page; page-break-before: always; }

Questions, answered

What does the Write a business plan skill do?

Read this before writing a business plan, planning the next three to ten years of a business, setting growth targets, or working out what has to change for the owner to get where they want to go. It is a document in the Agent Skills format: the steps, the rules and the reference files your AI reads when the job comes up. It is written for Xero, QuickBooks, and installs into any workspace whether or not those are connected.

How do I install it?

Add to FloConnector opens it inside your workspace, where Install puts it into one of your collections. Every profile carrying that collection has it on its next call. Download zip gives you the same skill as a bundle for any client that installs skills from disk.

Will it change after I install it?

Only if you ask it to. Keep updated follows FloConnector's revisions (this is v1) and records each one in the skill's history. Make my own is a copy that never changes unless you change it, and a kept-updated skill can be made editable later in one click.

Can I edit it or reuse it elsewhere?

Yes. You can copy, change, rename and redistribute it, commercially or not, with no attribution. Every skill in the library is published under CC0 1.0, and the zip carries the licence text.