jrecyclebin/freshbreath

Host static HTML apps and custom tools (APIs, SQLite, scripts) - then publish or use them through an MCP.

Go

3

266 commits

updated Oct 9, 2026

See the code

See what people are saying

SourceMessageScoreDate

Fresh Breath (personal app server) https://github.com/jrecyclebin/freshbreath

on Ask HN: What do you run on a $5 VPS that's worth keeping online 24/7?

0

Oct 9, 2026

README

Fresh Breath

๐“ ๐“น๐“ฎ๐“ป๐“ผ๐“ธ๐“ท๐“ช๐“ต ๐“ช๐“น๐“น ๐“ผ๐“ฎ๐“ป๐“ฟ๐“ฎ๐“ป.

  • For hosting apps.
  • For writing quick MCP wrappers around SQLite queries, HTTP APIs, or scripts.
  • For setting up connections (like Github or Google Drive) and giving apps access to their calls or their auth.
  • Fresh Breath itself is an MCP. Hook an agent up and build directly on it.

QUICK NOTE ON THAT: Personal not public. Don't just host this on the Internet!!

So - weirdly enough - this is a dream project I've had it in mind to do for ages. I love writing little personal tools - blogging tools, feed readers, task trackers, calendar tools - things that require some storage, some auth, some access. (My son recently made a tool for me to run a contest for friends over text message. I made an app to read online purchase receipts from my email to help me get a weekly summary. These apps sit side-by-side in Fresh Breath.)

I want these apps to be together and to share authentication and whatever integrations they need - oh, for me, another one was SSH access to other servers. Think of a homelab setup where you may want to automated tasks between them - or to publish to a VPS online.

And now that friends and family are cooking up their own little single-use apps - almost all of them standalone static HTML files - I realized that lots of people need this kind of thing!

โŒโ– _โ–  A UNIVERSAL ADAPTER

One way to think of Fresh Breath is as a universal adapter. I have an app that tracks personal finance - it was written by setting up the app and publishing it through MCP. The app is hosted on Fresh Breath so I can use through a browser - or I have a Telegram bot that accesses it through an MCP - so I can ask questions or get notified while I'm out. I can also send updates to YNAB, which is set up as a service, since I still like to use it for the time being.

If you find yourself writing lots of little apps that threaten to go beyond static HTML - they need basic auth, some integrations, some LLM calls, some file I/O - oh and proxying API calls to get around CORS - then this is the project for you. (Think of this almost like N8N or Zapier - a box of integrations - but then having the flexibility of actual client-side code to tie them together, rather than restrictive no-code platforms that always seem to have their limits.)

๐•†๐•™! ๐”ธ๐•๐•ค๐• !

I wanted these apps to allow people to log in using their OWN creds - of course. So you needn't make accounts in Fresh Breath for people who are just using the apps you build. Fresh Breath's whole point is to let you build apps that use the connections and credentials of the people using them - not some shared account that you set up for them.

In this way, you could host an app for the members of your workplace to use. It could be a dashboard showing Github and Linear activity. Your coworkers' creds are kept in their browser - no sessions are kept in Fresh Breath's database.

Rundown of Features

  • Set up MCP, OAuth and API connections for your apps to use. (Fresh Breath will handle the login and callback redirects for you - and it even tries to avoid needing client IDs and client secrets.)

    • Creds are stored in the browser, and what's stored is always a Fresh Breath token with any upstream credentials sealed inside it. The server unseals them on the way out; a static HTML file never holds a provider's token.
  • Services are proxied, if you allow it, to ensure that connections get through.

  • Upload static HTML apps that you've built with Cowork or other agent harnesses to host them. (Will automatically proxy these apps. Oh and you don't strictly NEED to do this - Fresh Breath will work with static HTML files you're just running from the file:// URL!! Great way to test your apps before publishing.)

  • Fresh Breath itself can be used as a local MCP - to publish work from a harness directly. The MCP has built-in guides to help guide the code writer through adding different auth and services. (But yeah, you can just say, "Hey, Claude, create a new Fresh Breath app for me and require a Github login to access it.")

    • Wrap any APIs into a "virtual" service. This is a set of tools that can be called from your apps or as its own MCP.

    • Even SQLite queries! Every Fresh Breath app can be given a database, which you then write tools for. (i.e. a list-items tool that executes a SELECT * FROM items query.)

    • Wrap Powershell or bash script into a "task" service. Same thing: can be called from anywhere.

  • Limit access to MCPs or APIs to specific calls. Limit what the LLM can do, to avoid headaches with apps that really should just have read-only access.

  • SSH support. Generate Fresh Breath-specific SSH keys you can use to login to the control panel and use to copy files generated by your app, sign git commits, run remote commands, etc.

  • Lightweight skills for publishing to Fresh Breath, setting up the MCP and API connections and guidance for using the SSH key.

Its control panel is used to register the different apps, set up the service each app needs and manage users, if you want to give others access to manage the apps and services.

Using Fresh Breath in your app

For a complete guide on loading the SDK, logging in, restoring sessions, and calling tools, see the detailed skill at skills/freshbreath/SKILL.md.

Every app hosted on Fresh Breath gets a random ID - used to identify itself. And every integration (auth connection, API, MCP) is identified by a primary URL.

Your app needs to include frbr.js from the server, call login() with the service URL you want to use - this gives your app access to functions like listTools(), callTool(), or fetch().

<script type="module">
  import { login } from "/frbr.js";

  const service = await login("https://mcp.example.com/mcp");
  const tools = await service.listTools();
  const result = await service.callTool("some_tool", { arg: "value" });
</script>

Tokens are automatically refreshed in these calls, if possible.

Admin panel

Visit /control in a browser to open the admin panel.

  • Register apps (static HTML projects that use the SDK)
  • Register services (MCP servers, HTTP APIs, task runners, virtual endpoints)
  • Register auth records and point services and apps at them, from either slot
  • Link services to apps so only permitted services work with each app
  • Manage users and assign them roles
  • Set which auth record gates the panel - and which every empty slot inherits
  • Review the audit log

You can also connect to /mcp on your Fresh Breath instance to log-in and do all of the above through the MCP.

Using With Claude Design

Quick trick off the bat: since Claude Design gives you a zip file, you can actually just drop that zip into Fresh Breath (the hosted apps page) and it'll host the design for you. (Like at https://localhost:9009/your-app.) Don't use the Claude Code handoff zip file, just the normal zip.

When Fresh Breath sees that the zip has a README, it'll offer you a prompt you can copy into Claude Code - instructing it to load the skill, build the app and publish changes back to Fresh Breath! This eases any work done adding auth, third-party connections, SSH, all that stuff.

Using With Claude Desktop and Cowork

If you want to use Fresh Breath (or any of its virtual or task services) as an MCP in Claude Desktop, you'll need to add an "mcpServers" entry to your config file.

I have a great tool for doing this - after getting frustrated with mcp-remote. The project is LMFriend - a very basic CLI for setting up and proxying local MCP servers with Claude Desktop.

lmfriend setup freshbreath=https://localhost:9009

It'll alter your config and add itself as the one to manage the connection. The nice thing is that, if you get logged out, the connection won't die. (If you've dealt with piles of popups showing connection difficulties in Claude Desktop, then you know.)

One nice thing for Cowork: you can drop artifacts from Cowork into Fresh Breath and it'll host them for you. (You'll need to set up the services it needs first, of course.) This is pretty new - there's a lot of work to do on it.

Deployment slots

Every hosted app gets three slots, each backed by its own folder:

  • Development โ€” the upload folder. Everything you drop into Fresh Breath lands here, unchanged from how hosting has always worked.
  • Staging โ€” a frozen copy of Development, made by deploying.
  • Production โ€” same idea, one step further along.

Uploads always go to Development. Deploying copies the current Development folder into Staging or Production (buttons in the app's hosting panel, or POST /api/apps/{nonce}/deploy with {"target":"staging"} โ€” pass "source":"staging" to promote Staging straight to Production).

Each slot is reachable at its own URL, and the bare app URL serves whichever slot the app's default environment names (Development unless you change it):

URLServes
/your-app@devDevelopment (the upload folder)
/your-app@stagingStaging
/your-app@prodProduction
/your-appthe app's default environment

Asking for a slot that hasn't been deployed yet (or setting the default environment to an undeployed slot) gets you an honest 404 โ€” Fresh Breath won't quietly substitute a different slot behind your back.

โš ๏ธ HARDCODED BASE PATHS

Apps that bake /your-app/ into their asset URLs or router base will miss when served from /your-app@staging. Prefer relative asset paths (./app.js) so the same build works in every slot.

The typical loop: iterate on Development (/your-app@dev), deploy to Staging to freeze a candidate, then deploy to Production and flip the app's default environment to Production so the bare URL goes live. Sites you don't care to stage can just live on Development forever โ€” the bare URL serves it by default.

Remote updates

I've started using Fresh Breath for projects I do for others. Give them a Fresh Breath installer with the apps and services they need, but then what if I need to update anything later on?

Remote updates are a way for one Fresh Breath instance to pull updates for its apps and services from an encrypted archive, hosted online. You add the URL in the Settings area and Fresh Breath will notify the user when there's an update available.

App updates go to the Staging slot - so you can either let the user test it and deploy to Production, or you can set the app's default environment to Staging so they can use it right away.

Setup and Config

Running the server

Grab a binary from the GitHub release. There is a Windows installer and zips that you can either centrally install or just unzip and run.

You can also install from Mise.

mise use -g github:jrecyclebin/freshbreath
freshbreath

Or there are Docker images for Linux:

docker run -d --name freshbreath \
  -p 9009:9009 \
  -v freshbreath-data:/data \
  ghcr.io/jrecyclebin/freshbreath:latest

The server starts on :9009 by default with an SQLite database at ./freshbreath.db.

Personal Installation

However you install it, Fresh Breath will figure out sensible defaults for where to keep its files. Start it up and visit http://localhost:9009/ to see the admin panel.

  • On Windows, you can just unzip wherever you like (even in C:\Program Files\Fresh Breath) and create a new folder %LOCALAPPDATA%\freshbreath where the database will be kept. The binary will pick up that second folder.

If you need to force some configuration, you can make a .env file in the same directory with the binary - and put environment variables in there. You can also do the same thing with a config.env in ~/.config/freshbreath - or %LOCALAPPDATA%\freshbreath on Windows.

๐ŸŽช THE LOCAL-UNZIP SWEET SPOT

For the common case โ€” unzipping the release and running from that folder โ€” everything resolves automatically: the binary finds web/ next to itself, the database lands in the current directory, and no env vars are needed.

Environment variables

VariableDefaultDescription
FRBR_DIRauto-detectedInstall directory containing web/ control panel
FRBR_DATA_DIRauto-detectedData directory for apps/, virtual/, tasks/ and database
FRBR_DB_PATH./freshbreath.dbSQLite database file
FRBR_BASE_URLauto-detectedDefaults to http://localhost:9009 or https://localhost:9009 depending on TLS
FRBR_LISTEN_ADDR:9009HTTP bind address
FRBR_TLS_CERT(none)Path to TLS certificate (PEM)
FRBR_TLS_KEY(none)Path to TLS private key (PEM)

When both FRBR_TLS_CERT and FRBR_TLS_KEY are provided, the server uses TLS (https:). Otherwise it serves plain HTTP.

Plain HTTP works for the control panel and hosted apps, but apps loaded from file:// or another origin can't stay signed in: browsers only send a cross-site cookie over HTTPS, so their logins lapse when the access token does.

TLS certificates

Built in โ€” on a fresh install served over plain HTTP, the control panel's onboarding offers to do what mkcert does: it makes a certificate authority for this install, issues a certificate for the address you reached it by (plus the machine name and loopback), and writes FRBR_TLS_CERT/FRBR_TLS_KEY into the env file the server loads (./.env, else config.env in the config dir). Download the CA from the panel, trust it on each device, and restart. Node-based clients such as Claude Code also need NODE_EXTRA_CA_CERTS pointed at it.

For local development โ€” use mkcert. It creates a local CA, installs it into your system and browser trust stores, and issues certs that Chromium treats as fully valid (no scary red screen).

# One-time setup
mkcert -install

# Issue a cert for localhost
mkcert localhost 127.0.0.1 ::1
# Creates ./localhost+2.pem and ./localhost+2-key.pem

Then set:

FRBR_TLS_CERT=./localhost+2.pem
FRBR_TLS_KEY=./localhost+2-key.pem
FRBR_BASE_URL=https://localhost:9009

For production โ€” Let's Encrypt:

sudo certbot certonly --standalone -d freshbreath.poggers.institute
# Certs land in /etc/letsencrypt/live/freshbreath.poggers.institute/

Point FRBR_TLS_CERT at fullchain.pem and FRBR_TLS_KEY at privkey.pem. Add a cron job or systemd timer to run certbot renew.

Building & testing

You'll need to install Mise.

mise build        # builds the 'freshbreath' binary
mise test         # runs the tests
mise check        # lint + tests
mise run          # run the dev server, reloads from code changes (requires `entr`)
mise e2e          # end-to-end suite (needs Chromium)

Building the Mac installer locally

The signed, notarized .pkg builds on a Mac with full Xcode. Signing uses the Developer ID Application and Developer ID Installer certificates in your login keychain (Xcode puts them there when you create them), and notarization uses a notarytool keychain profile โ€” so no key files sit on disk.

Once per Mac, save the App Store Connect API key as a profile, then name it in a mise.local.toml (gitignored):

xcrun notarytool store-credentials <profile> --key AuthKey_XXXX.p8 --key-id XXXX --issuer <uuid>
[env]
APPLE_NOTARY_PROFILE = "<profile>"

Then mise run build:mac-installer builds dist/freshbreath-<version>-macos-arm64-setup.pkg. CI does the same from the MACOS_CERTIFICATES_P12, MACOS_CERTIFICATES_PASSWORD and APPLE_API_KEY secrets plus the APPLE_API_KEY_ID and APPLE_API_ISSUER repo variables.

End-to-end tests

e2e/ runs a real server against in-process fakes of GitHub (an OAuth App plus a few REST routes) and a Notion-shaped MCP server behind OAuth. Each test sets things up over the admin API, then checks the data two ways: an uploaded app driven in headless Chromium, and an MCP client that logs in through the browser the way Claude Desktop does. No accounts, no network.

It's behind the e2e build tag, so mise test skips it. Chromium is found the way chromedp looks for it; set FRBR_E2E_CHROME to use a particular binary.

Building Docker Images

Docker images are based on the release tarballs. To build locally:

mise run docker:build   # static tarball โ†’ freshbreath:dev
mise run docker:run     # ...then serve it on :9009

That needs musl-gcc (apt install musl-tools, pacman -S musl), because the Linux binaries are statically linked โ€” see below.

Project layout

.
โ”œโ”€โ”€ cmd/freshbreath/          # Entry point: config resolution, server setup
โ”‚   โ”œโ”€โ”€ main.go
โ”‚   โ””โ”€โ”€ version.go            # Version + commit (set via ldflags)
โ”œโ”€โ”€ internal/
โ”‚   โ”œโ”€โ”€ db/                   # SQLite store, migrations, queries, types
โ”‚   โ”œโ”€โ”€ formats/              # Virtual-tool + task-file parsers
โ”‚   โ”œโ”€โ”€ server/               # HTTP server, handlers, MCP, OAuth, admin, /ssh/git
โ”‚   โ””โ”€โ”€ sshkit/               # SSH keys, agent, sessions, GitGateway
โ”œโ”€โ”€ web/
โ”‚   โ”œโ”€โ”€ control.html          # Admin panel shell
โ”‚   โ”œโ”€โ”€ frbr.js               # Frontend SDK (login + ServiceProxy)
โ”‚   โ””โ”€โ”€ control/
โ”‚       โ”œโ”€โ”€ app.js            # React admin app (JSX source)
โ”‚       โ”œโ”€โ”€ app.compiled.js   # Compiled app โ€” regenerate via scripts/compile-control.mjs
โ”‚       โ”œโ”€โ”€ fonts.css         # @font-face for the vendored Geist fonts
โ”‚       โ”œโ”€โ”€ fonts/            # Geist + Geist Mono woff2 (OFL)
โ”‚       โ”œโ”€โ”€ vendor/           # React/ReactDOM UMD + MCP SDK bundle (version-pinned)
โ”‚       โ”œโ”€โ”€ styles.css        # Theme
โ”‚       โ””โ”€โ”€ freshbreath.svg   # Logo
โ”œโ”€โ”€ skills/
โ”‚   โ””โ”€โ”€ freshbreath/
โ”‚       โ”œโ”€โ”€ SKILL.md          # Detailed SDK integration skill
โ”‚       โ””โ”€โ”€ guides/           # Publishing, services, SSH, tasks, virtuals
โ”œโ”€โ”€ samples/                  # Example apps (mcp.html, ssh.html, โ€ฆ)
โ”œโ”€โ”€ scripts/
โ”‚   โ””โ”€โ”€ build.sh              # Release packaging
โ””โ”€โ”€ mise.toml                 # Task definitions

The backend is a single Go module split into cmd/freshbreath (entry point) and internal/{db,formats,server,sshkit} packages. The admin panel is a single-file React app loaded from CDN. The frontend SDK is a small standalone ES module. No build step is required for the frontend โ€” the Go server serves everything directly.

jrecyclebin/freshbreath

Host static HTML apps and custom tools (APIs, SQLite, scripts) - then publish or use them through an MCP.

Go

3

266 commits

updated Oct 9, 2026

See the code

See what people are saying

SourceMessageScoreDate

Fresh Breath (personal app server) https://github.com/jrecyclebin/freshbreath

on Ask HN: What do you run on a $5 VPS that's worth keeping online 24/7?

0

Oct 9, 2026

README

Fresh Breath

๐“ ๐“น๐“ฎ๐“ป๐“ผ๐“ธ๐“ท๐“ช๐“ต ๐“ช๐“น๐“น ๐“ผ๐“ฎ๐“ป๐“ฟ๐“ฎ๐“ป.

  • For hosting apps.
  • For writing quick MCP wrappers around SQLite queries, HTTP APIs, or scripts.
  • For setting up connections (like Github or Google Drive) and giving apps access to their calls or their auth.
  • Fresh Breath itself is an MCP. Hook an agent up and build directly on it.

QUICK NOTE ON THAT: Personal not public. Don't just host this on the Internet!!

So - weirdly enough - this is a dream project I've had it in mind to do for ages. I love writing little personal tools - blogging tools, feed readers, task trackers, calendar tools - things that require some storage, some auth, some access. (My son recently made a tool for me to run a contest for friends over text message. I made an app to read online purchase receipts from my email to help me get a weekly summary. These apps sit side-by-side in Fresh Breath.)

I want these apps to be together and to share authentication and whatever integrations they need - oh, for me, another one was SSH access to other servers. Think of a homelab setup where you may want to automated tasks between them - or to publish to a VPS online.

And now that friends and family are cooking up their own little single-use apps - almost all of them standalone static HTML files - I realized that lots of people need this kind of thing!

โŒโ– _โ–  A UNIVERSAL ADAPTER

One way to think of Fresh Breath is as a universal adapter. I have an app that tracks personal finance - it was written by setting up the app and publishing it through MCP. The app is hosted on Fresh Breath so I can use through a browser - or I have a Telegram bot that accesses it through an MCP - so I can ask questions or get notified while I'm out. I can also send updates to YNAB, which is set up as a service, since I still like to use it for the time being.

If you find yourself writing lots of little apps that threaten to go beyond static HTML - they need basic auth, some integrations, some LLM calls, some file I/O - oh and proxying API calls to get around CORS - then this is the project for you. (Think of this almost like N8N or Zapier - a box of integrations - but then having the flexibility of actual client-side code to tie them together, rather than restrictive no-code platforms that always seem to have their limits.)

๐•†๐•™! ๐”ธ๐•๐•ค๐• !

I wanted these apps to allow people to log in using their OWN creds - of course. So you needn't make accounts in Fresh Breath for people who are just using the apps you build. Fresh Breath's whole point is to let you build apps that use the connections and credentials of the people using them - not some shared account that you set up for them.

In this way, you could host an app for the members of your workplace to use. It could be a dashboard showing Github and Linear activity. Your coworkers' creds are kept in their browser - no sessions are kept in Fresh Breath's database.

Rundown of Features

  • Set up MCP, OAuth and API connections for your apps to use. (Fresh Breath will handle the login and callback redirects for you - and it even tries to avoid needing client IDs and client secrets.)

    • Creds are stored in the browser, and what's stored is always a Fresh Breath token with any upstream credentials sealed inside it. The server unseals them on the way out; a static HTML file never holds a provider's token.
  • Services are proxied, if you allow it, to ensure that connections get through.

  • Upload static HTML apps that you've built with Cowork or other agent harnesses to host them. (Will automatically proxy these apps. Oh and you don't strictly NEED to do this - Fresh Breath will work with static HTML files you're just running from the file:// URL!! Great way to test your apps before publishing.)

  • Fresh Breath itself can be used as a local MCP - to publish work from a harness directly. The MCP has built-in guides to help guide the code writer through adding different auth and services. (But yeah, you can just say, "Hey, Claude, create a new Fresh Breath app for me and require a Github login to access it.")

    • Wrap any APIs into a "virtual" service. This is a set of tools that can be called from your apps or as its own MCP.

    • Even SQLite queries! Every Fresh Breath app can be given a database, which you then write tools for. (i.e. a list-items tool that executes a SELECT * FROM items query.)

    • Wrap Powershell or bash script into a "task" service. Same thing: can be called from anywhere.

  • Limit access to MCPs or APIs to specific calls. Limit what the LLM can do, to avoid headaches with apps that really should just have read-only access.

  • SSH support. Generate Fresh Breath-specific SSH keys you can use to login to the control panel and use to copy files generated by your app, sign git commits, run remote commands, etc.

  • Lightweight skills for publishing to Fresh Breath, setting up the MCP and API connections and guidance for using the SSH key.

Its control panel is used to register the different apps, set up the service each app needs and manage users, if you want to give others access to manage the apps and services.

Using Fresh Breath in your app

For a complete guide on loading the SDK, logging in, restoring sessions, and calling tools, see the detailed skill at skills/freshbreath/SKILL.md.

Every app hosted on Fresh Breath gets a random ID - used to identify itself. And every integration (auth connection, API, MCP) is identified by a primary URL.

Your app needs to include frbr.js from the server, call login() with the service URL you want to use - this gives your app access to functions like listTools(), callTool(), or fetch().

<script type="module">
  import { login } from "/frbr.js";

  const service = await login("https://mcp.example.com/mcp");
  const tools = await service.listTools();
  const result = await service.callTool("some_tool", { arg: "value" });
</script>

Tokens are automatically refreshed in these calls, if possible.

Admin panel

Visit /control in a browser to open the admin panel.

  • Register apps (static HTML projects that use the SDK)
  • Register services (MCP servers, HTTP APIs, task runners, virtual endpoints)
  • Register auth records and point services and apps at them, from either slot
  • Link services to apps so only permitted services work with each app
  • Manage users and assign them roles
  • Set which auth record gates the panel - and which every empty slot inherits
  • Review the audit log

You can also connect to /mcp on your Fresh Breath instance to log-in and do all of the above through the MCP.

Using With Claude Design

Quick trick off the bat: since Claude Design gives you a zip file, you can actually just drop that zip into Fresh Breath (the hosted apps page) and it'll host the design for you. (Like at https://localhost:9009/your-app.) Don't use the Claude Code handoff zip file, just the normal zip.

When Fresh Breath sees that the zip has a README, it'll offer you a prompt you can copy into Claude Code - instructing it to load the skill, build the app and publish changes back to Fresh Breath! This eases any work done adding auth, third-party connections, SSH, all that stuff.

Using With Claude Desktop and Cowork

If you want to use Fresh Breath (or any of its virtual or task services) as an MCP in Claude Desktop, you'll need to add an "mcpServers" entry to your config file.

I have a great tool for doing this - after getting frustrated with mcp-remote. The project is LMFriend - a very basic CLI for setting up and proxying local MCP servers with Claude Desktop.

lmfriend setup freshbreath=https://localhost:9009

It'll alter your config and add itself as the one to manage the connection. The nice thing is that, if you get logged out, the connection won't die. (If you've dealt with piles of popups showing connection difficulties in Claude Desktop, then you know.)

One nice thing for Cowork: you can drop artifacts from Cowork into Fresh Breath and it'll host them for you. (You'll need to set up the services it needs first, of course.) This is pretty new - there's a lot of work to do on it.

Deployment slots

Every hosted app gets three slots, each backed by its own folder:

  • Development โ€” the upload folder. Everything you drop into Fresh Breath lands here, unchanged from how hosting has always worked.
  • Staging โ€” a frozen copy of Development, made by deploying.
  • Production โ€” same idea, one step further along.

Uploads always go to Development. Deploying copies the current Development folder into Staging or Production (buttons in the app's hosting panel, or POST /api/apps/{nonce}/deploy with {"target":"staging"} โ€” pass "source":"staging" to promote Staging straight to Production).

Each slot is reachable at its own URL, and the bare app URL serves whichever slot the app's default environment names (Development unless you change it):

URLServes
/your-app@devDevelopment (the upload folder)
/your-app@stagingStaging
/your-app@prodProduction
/your-appthe app's default environment

Asking for a slot that hasn't been deployed yet (or setting the default environment to an undeployed slot) gets you an honest 404 โ€” Fresh Breath won't quietly substitute a different slot behind your back.

โš ๏ธ HARDCODED BASE PATHS

Apps that bake /your-app/ into their asset URLs or router base will miss when served from /your-app@staging. Prefer relative asset paths (./app.js) so the same build works in every slot.

The typical loop: iterate on Development (/your-app@dev), deploy to Staging to freeze a candidate, then deploy to Production and flip the app's default environment to Production so the bare URL goes live. Sites you don't care to stage can just live on Development forever โ€” the bare URL serves it by default.

Remote updates

I've started using Fresh Breath for projects I do for others. Give them a Fresh Breath installer with the apps and services they need, but then what if I need to update anything later on?

Remote updates are a way for one Fresh Breath instance to pull updates for its apps and services from an encrypted archive, hosted online. You add the URL in the Settings area and Fresh Breath will notify the user when there's an update available.

App updates go to the Staging slot - so you can either let the user test it and deploy to Production, or you can set the app's default environment to Staging so they can use it right away.

Setup and Config

Running the server

Grab a binary from the GitHub release. There is a Windows installer and zips that you can either centrally install or just unzip and run.

You can also install from Mise.

mise use -g github:jrecyclebin/freshbreath
freshbreath

Or there are Docker images for Linux:

docker run -d --name freshbreath \
  -p 9009:9009 \
  -v freshbreath-data:/data \
  ghcr.io/jrecyclebin/freshbreath:latest

The server starts on :9009 by default with an SQLite database at ./freshbreath.db.

Personal Installation

However you install it, Fresh Breath will figure out sensible defaults for where to keep its files. Start it up and visit http://localhost:9009/ to see the admin panel.

  • On Windows, you can just unzip wherever you like (even in C:\Program Files\Fresh Breath) and create a new folder %LOCALAPPDATA%\freshbreath where the database will be kept. The binary will pick up that second folder.

If you need to force some configuration, you can make a .env file in the same directory with the binary - and put environment variables in there. You can also do the same thing with a config.env in ~/.config/freshbreath - or %LOCALAPPDATA%\freshbreath on Windows.

๐ŸŽช THE LOCAL-UNZIP SWEET SPOT

For the common case โ€” unzipping the release and running from that folder โ€” everything resolves automatically: the binary finds web/ next to itself, the database lands in the current directory, and no env vars are needed.

Environment variables

VariableDefaultDescription
FRBR_DIRauto-detectedInstall directory containing web/ control panel
FRBR_DATA_DIRauto-detectedData directory for apps/, virtual/, tasks/ and database
FRBR_DB_PATH./freshbreath.dbSQLite database file
FRBR_BASE_URLauto-detectedDefaults to http://localhost:9009 or https://localhost:9009 depending on TLS
FRBR_LISTEN_ADDR:9009HTTP bind address
FRBR_TLS_CERT(none)Path to TLS certificate (PEM)
FRBR_TLS_KEY(none)Path to TLS private key (PEM)

When both FRBR_TLS_CERT and FRBR_TLS_KEY are provided, the server uses TLS (https:). Otherwise it serves plain HTTP.

Plain HTTP works for the control panel and hosted apps, but apps loaded from file:// or another origin can't stay signed in: browsers only send a cross-site cookie over HTTPS, so their logins lapse when the access token does.

TLS certificates

Built in โ€” on a fresh install served over plain HTTP, the control panel's onboarding offers to do what mkcert does: it makes a certificate authority for this install, issues a certificate for the address you reached it by (plus the machine name and loopback), and writes FRBR_TLS_CERT/FRBR_TLS_KEY into the env file the server loads (./.env, else config.env in the config dir). Download the CA from the panel, trust it on each device, and restart. Node-based clients such as Claude Code also need NODE_EXTRA_CA_CERTS pointed at it.

For local development โ€” use mkcert. It creates a local CA, installs it into your system and browser trust stores, and issues certs that Chromium treats as fully valid (no scary red screen).

# One-time setup
mkcert -install

# Issue a cert for localhost
mkcert localhost 127.0.0.1 ::1
# Creates ./localhost+2.pem and ./localhost+2-key.pem

Then set:

FRBR_TLS_CERT=./localhost+2.pem
FRBR_TLS_KEY=./localhost+2-key.pem
FRBR_BASE_URL=https://localhost:9009

For production โ€” Let's Encrypt:

sudo certbot certonly --standalone -d freshbreath.poggers.institute
# Certs land in /etc/letsencrypt/live/freshbreath.poggers.institute/

Point FRBR_TLS_CERT at fullchain.pem and FRBR_TLS_KEY at privkey.pem. Add a cron job or systemd timer to run certbot renew.

Building & testing

You'll need to install Mise.

mise build        # builds the 'freshbreath' binary
mise test         # runs the tests
mise check        # lint + tests
mise run          # run the dev server, reloads from code changes (requires `entr`)
mise e2e          # end-to-end suite (needs Chromium)

Building the Mac installer locally

The signed, notarized .pkg builds on a Mac with full Xcode. Signing uses the Developer ID Application and Developer ID Installer certificates in your login keychain (Xcode puts them there when you create them), and notarization uses a notarytool keychain profile โ€” so no key files sit on disk.

Once per Mac, save the App Store Connect API key as a profile, then name it in a mise.local.toml (gitignored):

xcrun notarytool store-credentials <profile> --key AuthKey_XXXX.p8 --key-id XXXX --issuer <uuid>
[env]
APPLE_NOTARY_PROFILE = "<profile>"

Then mise run build:mac-installer builds dist/freshbreath-<version>-macos-arm64-setup.pkg. CI does the same from the MACOS_CERTIFICATES_P12, MACOS_CERTIFICATES_PASSWORD and APPLE_API_KEY secrets plus the APPLE_API_KEY_ID and APPLE_API_ISSUER repo variables.

End-to-end tests

e2e/ runs a real server against in-process fakes of GitHub (an OAuth App plus a few REST routes) and a Notion-shaped MCP server behind OAuth. Each test sets things up over the admin API, then checks the data two ways: an uploaded app driven in headless Chromium, and an MCP client that logs in through the browser the way Claude Desktop does. No accounts, no network.

It's behind the e2e build tag, so mise test skips it. Chromium is found the way chromedp looks for it; set FRBR_E2E_CHROME to use a particular binary.

Building Docker Images

Docker images are based on the release tarballs. To build locally:

mise run docker:build   # static tarball โ†’ freshbreath:dev
mise run docker:run     # ...then serve it on :9009

That needs musl-gcc (apt install musl-tools, pacman -S musl), because the Linux binaries are statically linked โ€” see below.

Project layout

.
โ”œโ”€โ”€ cmd/freshbreath/          # Entry point: config resolution, server setup
โ”‚   โ”œโ”€โ”€ main.go
โ”‚   โ””โ”€โ”€ version.go            # Version + commit (set via ldflags)
โ”œโ”€โ”€ internal/
โ”‚   โ”œโ”€โ”€ db/                   # SQLite store, migrations, queries, types
โ”‚   โ”œโ”€โ”€ formats/              # Virtual-tool + task-file parsers
โ”‚   โ”œโ”€โ”€ server/               # HTTP server, handlers, MCP, OAuth, admin, /ssh/git
โ”‚   โ””โ”€โ”€ sshkit/               # SSH keys, agent, sessions, GitGateway
โ”œโ”€โ”€ web/
โ”‚   โ”œโ”€โ”€ control.html          # Admin panel shell
โ”‚   โ”œโ”€โ”€ frbr.js               # Frontend SDK (login + ServiceProxy)
โ”‚   โ””โ”€โ”€ control/
โ”‚       โ”œโ”€โ”€ app.js            # React admin app (JSX source)
โ”‚       โ”œโ”€โ”€ app.compiled.js   # Compiled app โ€” regenerate via scripts/compile-control.mjs
โ”‚       โ”œโ”€โ”€ fonts.css         # @font-face for the vendored Geist fonts
โ”‚       โ”œโ”€โ”€ fonts/            # Geist + Geist Mono woff2 (OFL)
โ”‚       โ”œโ”€โ”€ vendor/           # React/ReactDOM UMD + MCP SDK bundle (version-pinned)
โ”‚       โ”œโ”€โ”€ styles.css        # Theme
โ”‚       โ””โ”€โ”€ freshbreath.svg   # Logo
โ”œโ”€โ”€ skills/
โ”‚   โ””โ”€โ”€ freshbreath/
โ”‚       โ”œโ”€โ”€ SKILL.md          # Detailed SDK integration skill
โ”‚       โ””โ”€โ”€ guides/           # Publishing, services, SSH, tasks, virtuals
โ”œโ”€โ”€ samples/                  # Example apps (mcp.html, ssh.html, โ€ฆ)
โ”œโ”€โ”€ scripts/
โ”‚   โ””โ”€โ”€ build.sh              # Release packaging
โ””โ”€โ”€ mise.toml                 # Task definitions

The backend is a single Go module split into cmd/freshbreath (entry point) and internal/{db,formats,server,sshkit} packages. The admin panel is a single-file React app loaded from CDN. The frontend SDK is a small standalone ES module. No build step is required for the frontend โ€” the Go server serves everything directly.