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
๐ ๐น๐ฎ๐ป๐ผ๐ธ๐ท๐ช๐ต ๐ช๐น๐น ๐ผ๐ฎ๐ป๐ฟ๐ฎ๐ป.
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.
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.)
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.
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.
Visit /control in a browser to open the admin panel.
You can also connect to /mcp on your Fresh Breath instance to log-in and
do all of the above through the MCP.
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.
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.
Every hosted app gets three slots, each backed by its own folder:
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):
| URL | Serves |
|---|---|
/your-app@dev | Development (the upload folder) |
/your-app@staging | Staging |
/your-app@prod | Production |
/your-app | the 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.
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.
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.
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.
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.
| Variable | Default | Description |
|---|---|---|
FRBR_DIR | auto-detected | Install directory containing web/ control panel |
FRBR_DATA_DIR | auto-detected | Data directory for apps/, virtual/, tasks/ and database |
FRBR_DB_PATH | ./freshbreath.db | SQLite database file |
FRBR_BASE_URL | auto-detected | Defaults to http://localhost:9009 or https://localhost:9009 depending on TLS |
FRBR_LISTEN_ADDR | :9009 | HTTP 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.
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.
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)
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.
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.
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.
.
โโโ 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.
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
๐ ๐น๐ฎ๐ป๐ผ๐ธ๐ท๐ช๐ต ๐ช๐น๐น ๐ผ๐ฎ๐ป๐ฟ๐ฎ๐ป.
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.
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.)
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.
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.
Visit /control in a browser to open the admin panel.
You can also connect to /mcp on your Fresh Breath instance to log-in and
do all of the above through the MCP.
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.
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.
Every hosted app gets three slots, each backed by its own folder:
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):
| URL | Serves |
|---|---|
/your-app@dev | Development (the upload folder) |
/your-app@staging | Staging |
/your-app@prod | Production |
/your-app | the 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.
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.
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.
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.
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.
| Variable | Default | Description |
|---|---|---|
FRBR_DIR | auto-detected | Install directory containing web/ control panel |
FRBR_DATA_DIR | auto-detected | Data directory for apps/, virtual/, tasks/ and database |
FRBR_DB_PATH | ./freshbreath.db | SQLite database file |
FRBR_BASE_URL | auto-detected | Defaults to http://localhost:9009 or https://localhost:9009 depending on TLS |
FRBR_LISTEN_ADDR | :9009 | HTTP 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.
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.
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)
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.
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.
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.
.
โโโ 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.