24 KiB
Toes User Guide
Toes is a personal web appliance that runs multiple web apps on your home network. Plug it in, turn it on, and forget about the cloud.
Table of Contents
- Quick Start
- Creating an App
- Creating a Tool
- CLI Reference
- Environment Variables
- Health Checks
- Running over HTTP
- App Lifecycle
- Cron Jobs
- Data Persistence
Quick Start
# Install the CLI
curl -fsSL http://toes.local/install | bash
# Create a new app (scaffolds, inits git, and pushes to server)
toes new my-app
# Enter the directory, install deps, and develop locally
cd my-app
bun install
bun dev
# Deploy changes (standard git)
git add . && git commit -m "my changes"
git push toes main
# Open in browser
toes open
Your app is now running at http://my-app.toes.local.
toes new automatically sets up a toes git remote pointing at the server. Pushing to it triggers a deploy.
Creating an App
App Templates
Toes ships with three templates. Pick one when creating an app:
toes new my-app # SSR (default)
toes new my-app --bare # Minimal
toes new my-app --spa # Single-page app
SSR — Server-side rendered with a pages directory. Best for most apps. Uses Hype's built-in layout and page routing.
Bare — Just an index.tsx with a single route. Good when you want to start from scratch.
SPA — Client-side rendering with hono/jsx/dom. Hype serves the HTML shell and static files; the browser handles routing and rendering.
App Structure
A generated SSR app looks like this:
my-app/
.gitignore # Files to exclude from sync and deploy
.npmrc # Points to the private registry
package.json # Must have scripts.toes
tsconfig.json # TypeScript config
index.tsx # Entry point (re-exports from src/server)
src/
server/
index.tsx # Hype app with routes
pages/
index.tsx # Page components
The Bare Minimum
Every app needs three things:
package.jsonwith ascripts.toesentryindex.tsxthat exportsapp.defaults- A
GET /okroute that returns 200 (health check)
package.json:
{
"name": "my-app",
"module": "index.tsx",
"type": "module",
"private": true,
"scripts": {
"toes": "bun run --watch index.tsx"
},
"toes": {
"icon": "🎨"
},
"dependencies": {
"@because/hype": "*",
"@because/forge": "*"
}
}
The scripts.toes field is how Toes discovers your app. The toes.icon field sets the emoji shown in the dashboard.
.npmrc:
registry=https://npm.nose.space
Required for installing @because/* packages.
index.tsx:
import { Hype } from '@because/hype'
const app = new Hype()
app.get('/', c => c.html(<h1>Hello World</h1>))
app.get('/ok', c => c.text('ok'))
export default app.defaults
That's it. Push to the server and it runs.
Using Hype
Hype wraps Hono. Everything you know from Hono works here. Hype adds a few extras:
Basic routing:
import { Hype } from '@because/hype'
const app = new Hype()
app.get('/', c => c.html(<h1>Home</h1>))
app.get('/about', c => c.html(<h1>About</h1>))
app.post('/api/items', async c => {
const body = await c.req.json()
return c.json({ ok: true })
})
app.get('/ok', c => c.text('ok'))
export default app.defaults
Sub-routers:
const api = Hype.router()
api.get('/items', c => c.json([]))
api.post('/items', async c => {
const body = await c.req.json()
return c.json({ ok: true })
})
app.route('/api', api) // mounts at /api/items
Server-Sent Events:
app.sse('/stream', (send, c) => {
send({ hello: 'world' })
const interval = setInterval(() => send({ time: Date.now() }), 1000)
return () => clearInterval(interval) // cleanup on disconnect
})
Constructor options:
const app = new Hype({
layout: true, // Wraps pages in an HTML layout (default: true)
prettyHTML: true, // Pretty-print HTML output (default: true)
logging: true, // Log requests to stdout (default: true)
})
Using Forge
Forge is a CSS-in-JS library that creates styled JSX components. Define a component once, use it everywhere.
Basic usage:
import { define, stylesToCSS } from '@because/forge'
const Box = define('Box', {
padding: 20,
borderRadius: '6px',
backgroundColor: '#f5f5f5',
})
// <Box>content</Box> renders <div class="Box">content</div>
Numbers auto-convert to px (except flex, opacity, zIndex, fontWeight).
Set the HTML element:
const Button = define('Button', { base: 'button', padding: '8px 16px' })
const Link = define('Link', { base: 'a', textDecoration: 'none' })
const Input = define('Input', { base: 'input', padding: 8, border: '1px solid #ccc' })
Pseudo-classes (states):
const Item = define('Item', {
padding: 12,
states: {
':hover': { backgroundColor: '#eee' },
':last-child': { borderBottom: 'none' },
},
})
Nested selectors:
const List = define('List', {
selectors: {
'& > li:last-child': { borderBottom: 'none' },
},
})
Variants:
const Button = define('Button', {
base: 'button',
padding: '8px 16px',
variants: {
variant: {
primary: { backgroundColor: '#2563eb', color: 'white' },
danger: { backgroundColor: '#dc2626', color: 'white' },
},
},
})
// <Button variant="primary">Save</Button>
Serving CSS:
Forge generates CSS at runtime. Serve it from a route:
import { stylesToCSS } from '@because/forge'
app.get('/styles.css', c =>
c.text(stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
)
Then link it in your HTML:
<link rel="stylesheet" href="/styles.css" />
Creating a Tool
What's a Tool?
A tool is an app that appears as a tab inside the Toes dashboard instead of in the sidebar. Tools render in an iframe and receive the currently selected app as a ?app= query parameter. Good for things like a code editor, log viewer, env manager, or cron scheduler.
From the server's perspective, a tool is identical to an app — same lifecycle, same health checks, same port allocation. The only differences are in package.json and how you render.
Tool Setup
A tool needs three extra things compared to a regular app:
- Set
"tool": trueinpackage.json - Include
<ToolScript />in the HTML body - Prepend
baseStylesto CSS output
package.json:
{
"name": "my-tool",
"module": "index.tsx",
"type": "module",
"private": true,
"scripts": {
"toes": "bun run --watch index.tsx"
},
"toes": {
"icon": "🔧",
"tool": true
},
"dependencies": {
"@because/forge": "*",
"@because/hype": "*",
"@because/toes": "*"
}
}
Set "tool" to true for a tab labeled with the app name, or to a string for a custom label (e.g., "tool": ".env").
index.tsx:
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import type { Child } from 'hono/jsx'
const app = new Hype({ prettyHTML: false })
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
paddingTop: 0,
maxWidth: '800px',
margin: '0 auto',
color: theme('colors-text'),
})
function Layout({ title, children }: { title: string; children: Child }) {
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<ToolScript />
<Container>{children}</Container>
</body>
</html>
)
}
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c =>
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
)
app.get('/', async c => {
const appName = c.req.query('app')
if (!appName) {
return c.html(<Layout title="My Tool"><p>No app selected</p></Layout>)
}
return c.html(
<Layout title="My Tool">
<h2>{appName}</h2>
<p>Tool content for {appName}</p>
</Layout>
)
})
export default app.defaults
Key points:
<ToolScript />handles dark/light mode syncing and iframe height communication with the dashboard.baseStylessets the body background to match the dashboard theme.prettyHTML: falseis recommended for tools since their output is inside an iframe.- The
?app=query parameter tells you which app the user has selected in the sidebar.
Theme Tokens
Tools should use theme tokens to match the dashboard's look. Import theme from @because/toes/tools:
import { theme } from '@because/toes/tools'
const Card = define('Card', {
color: theme('colors-text'),
backgroundColor: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
padding: theme('spacing-lg'),
})
Available tokens:
| Token | Description |
|---|---|
colors-bg |
Page background |
colors-bgSubtle |
Subtle background |
colors-bgElement |
Element background (cards, inputs) |
colors-bgHover |
Hover background |
colors-text |
Primary text |
colors-textMuted |
Secondary text |
colors-textFaint |
Tertiary/disabled text |
colors-border |
Borders |
colors-link |
Link text |
colors-primary |
Primary action color |
colors-primaryText |
Text on primary color |
colors-error |
Error color |
colors-dangerBorder |
Danger state border |
colors-dangerText |
Danger text |
colors-success |
Success color |
colors-successBg |
Success background |
colors-statusRunning |
Running indicator |
colors-statusStopped |
Stopped indicator |
fonts-sans |
Sans-serif font stack |
fonts-mono |
Monospace font stack |
spacing-xs |
4px |
spacing-sm |
8px |
spacing-md |
12px |
spacing-lg |
16px |
spacing-xl |
24px |
radius-md |
6px |
Accessing App Data
Reading app files:
import { join } from 'path'
const APPS_DIR = process.env.APPS_DIR!
app.get('/', c => {
const appName = c.req.query('app')
if (!appName) return c.html(<p>No app selected</p>)
const appPath = join(APPS_DIR, appName)
// Read files from appPath...
})
Calling the Toes API:
const TOES_URL = process.env.TOES_URL!
// List all apps
const apps = await fetch(`${TOES_URL}/api/apps`).then(r => r.json())
// Get a specific app
const app = await fetch(`${TOES_URL}/api/apps/${name}`).then(r => r.json())
Linking between tools:
<a href="/tool/code?app=my-app&file=index.tsx">Edit in Code</a>
Tool URLs go through /tool/:name which redirects to the tool's subdomain with query params preserved.
Listening to lifecycle events:
import { on } from '@because/toes/tools'
const unsub = on('app:start', event => {
console.log(`${event.app} started at ${event.time}`)
})
// Event types: 'app:start', 'app:stop', 'app:create', 'app:delete', 'app:activate'
CLI Reference
The CLI connects to your Toes server over HTTP. By default it connects to http://toes.local. Set TOES_URL to point elsewhere, or set DEV=1 to use http://localhost:3000.
Most commands accept an optional app name. If omitted, the CLI uses the current directory's package.json name.
App Management
toes list — List all apps and their status.
toes list # Show apps and tools
toes list --apps # Apps only (exclude tools)
toes list --tools # Tools only
toes new [name] — Create a new app from a template.
toes new my-app # SSR template (default)
toes new my-app --bare # Minimal template
toes new my-app --spa # SPA template
Scaffolds the app locally, initializes a git repo with a toes remote pointing at the server, and pushes. The git push triggers a deploy. If run without a name, scaffolds the current directory.
toes info [name] — Show details for an app (state, URL, port, PID, uptime).
toes get <name> — Clone an app from the server to your local machine.
toes get my-app # Clones into ./my-app/
cd my-app
bun install
bun dev # Develop locally
The clone comes with a toes remote already configured, so git push toes main deploys.
toes open [name] — Open a running app in your browser.
toes rename [name] <new-name> — Rename an app. Requires typing a confirmation.
toes rm [name] — Permanently delete an app from the server. Requires typing a confirmation.
Lifecycle
toes start [name] — Start a stopped app.
toes stop [name] — Stop a running app.
toes restart [name] — Stop and start an app.
toes logs [name] — View logs for an app.
toes logs my-app # Today's logs
toes logs my-app -f # Follow (tail) logs in real-time
toes logs my-app -d 2026-01-15 # Logs from a specific date
toes logs my-app -s 2d # Logs from the last 2 days
toes logs my-app -g error # Filter logs by pattern
toes logs my-app -f -g error # Follow and filter
Duration formats for --since: 1h (hours), 2d (days), 1w (weeks), 1m (months).
Deploying Code
Toes uses git for deployments. Each app has a toes remote that points to the server's git tool. Pushing to it extracts the latest commit and deploys it.
# Make changes, commit, and deploy
git add .
git commit -m "update homepage"
git push toes main
The git push triggers the server to:
- Store the commit in a bare repo at
DATA_DIR/repos/<name>.git - Extract HEAD into the app directory
- Run
bun installand restart the app
Use standard git commands for history, diffing, and rollback:
git log # View deploy history
git diff HEAD~1 # See what changed
git revert HEAD # Undo last deploy
git push toes main # Deploy the revert
To clone an existing app from the server:
git clone http://git.toes.local/my-app
cd my-app
bun install
bun dev # Develop locally
Environment Variables
toes env [name] — List environment variables for an app.
toes env my-app # List app vars
toes env -g # List global vars
toes env set [name] <KEY> [value] — Set a variable.
toes env set my-app API_KEY sk-123 # Set for an app
toes env set my-app API_KEY=sk-123 # KEY=value format also works
toes env set -g API_KEY sk-123 # Set globally (shared by all apps)
Setting a variable automatically restarts the app.
toes env rm [name] <KEY> — Remove a variable.
toes env rm my-app API_KEY # Remove from an app
toes env rm -g API_KEY # Remove global var
Versioning
Every git push toes main creates a new deploy. Version history is managed through git.
git log --oneline # List deploys
git revert HEAD # Undo last change
git push toes main # Deploy the revert
Cron Jobs
Cron commands talk to the cron tool running on your Toes server.
toes cron [app] — List all cron jobs, or jobs for a specific app.
toes cron status <app:name> — Show details for a specific job.
toes cron status my-app:backup
# my-app:backup ok
#
# Schedule: day
# State: idle
# Last run: 2h ago
# Duration: 3s
# Exit code: 0
# Next run: in 22h
toes cron run <app:name> — Trigger a job immediately.
toes cron run my-app:backup
toes cron log [target] — View cron logs.
toes cron log # All cron logs
toes cron log my-app # Cron logs for an app
toes cron log my-app:backup # Logs for a specific job
toes cron log -f # Follow logs
Metrics
toes metrics [name] — Show CPU, memory, and disk usage.
toes metrics # All apps
toes metrics my-app # Single app
Sharing
toes share [name] — Create a public tunnel to share an app over the internet.
toes share my-app
# ↗ Sharing my-app... https://myapp.toes.space
toes unshare [name] — Stop sharing an app.
Every request to your app includes an x-app-url header with the app's public-facing URL. When shared, this is the tunnel URL (e.g., https://myapp.toes.space). When not shared, it's the local URL (e.g., http://myapp.toes.local). This works whether the request arrives through the local proxy or through a tunnel.
Use appUrl() from @because/toes/tools to read it — never hardcode your app's URL:
import { appUrl } from '@because/toes/tools'
app.get('/callback', c => {
const url = appUrl(c.req.raw)
// "https://myapp.toes.space" when shared, "http://myapp.toes.local" otherwise
return c.redirect(`${url}/done`)
})
Environment Variables
Toes injects these variables into every app process automatically:
| Variable | Description |
|---|---|
PORT |
Assigned port (3001-3100). Your app must listen on this port. |
APPS_DIR |
Path to the apps directory on the server. |
DATA_DIR |
Per-app data directory for persistent storage. |
TOES_URL |
Base URL of the Toes server (e.g., http://toes.local). |
TOES_DIR |
Path to the Toes config directory. |
APP_URL |
The app's local URL (e.g., http://myapp.toes.local). For the public URL that accounts for sharing, use appUrl(req) from @because/toes (see Sharing). |
You can set custom variables per-app or globally. Global variables are inherited by all apps. Per-app variables override globals.
# Set per-app
toes env set my-app OPENAI_API_KEY sk-123
# Set globally (shared by all apps)
toes env set -g DATABASE_URL postgres://localhost/mydb
Access them in your app:
const apiKey = process.env.OPENAI_API_KEY
Health Checks
Toes checks GET /ok on every app every 30 seconds. Your app must return a 2xx response.
Three consecutive failures trigger an automatic restart with exponential backoff (1s, 2s, 4s, 8s, 16s, 32s). After 5 restart failures, the app is marked as errored and restart is disabled.
The simplest health check:
app.get('/ok', c => c.text('ok'))
Running over HTTP
Toes serves apps over plain HTTP (http://<app>.toes.local), not HTTPS. This is fine for a home network appliance, but a few browser features assume HTTPS and will silently break if you're not aware of them.
Note:
localhostgets a special pass — browsers treat it as a secure context even over HTTP. But.localdomains don't get that exemption, so these gotchas apply when accessing your apps at<app>.toes.localfrom another device.
Cookies
If you set cookies with the Secure flag, browsers will silently ignore them — the cookie just won't be stored.
Don't do this:
c.header('Set-Cookie', 'session=abc123; HttpOnly; Secure; SameSite=Lax')
Do this instead:
c.header('Set-Cookie', 'session=abc123; HttpOnly; SameSite=Lax')
If you're using a cookie library, make sure secure is set to false (or omitted):
import { setCookie } from 'hono/cookie'
setCookie(c, 'session', token, {
httpOnly: true,
sameSite: 'Lax',
secure: false, // toes apps run over HTTP
})
Clipboard API
navigator.clipboard.writeText() and navigator.clipboard.readText() require a secure context. They'll throw on .local domains.
Use the legacy fallback instead:
function copyToClipboard(text: string) {
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
Service Workers
Service workers only register on HTTPS origins (plus localhost). If you're building a PWA or want offline caching, it won't work on .local. This is a hard browser restriction with no workaround.
Web Push Notifications
The Push API and Notification.requestPermission() require a secure context. For notifications on the local network, consider polling or SSE instead:
app.sse('/notifications', (send, c) => {
// push updates over SSE instead of Web Push
send({ title: 'New item', body: 'Something happened' })
return () => {}
})
Geolocation & Camera/Mic
navigator.geolocation and navigator.mediaDevices.getUserMedia() require a secure context. These won't work on .local domains.
Web Crypto
crypto.subtle (for hashing, encryption, key generation) requires a secure context. Use a library like tweetnacl if you need crypto in the browser, or do it server-side:
// Server-side — works fine, no secure context needed
const hash = new Bun.CryptoHasher('sha256').update(data).digest('hex')
What about toes share?
toes share tunnels your app through HTTPS, so all of the above works when accessed through the tunnel URL. But since your app should also work locally, don't rely on secure-context APIs unless you're okay with them only working when shared.
App Lifecycle
Apps move through these states:
invalid → stopped → starting → running → stopping → stopped
↓
error
- invalid — Missing
package.jsonorscripts.toes. Fix the config and start manually. - stopped — Not running. Start with
toes startor the dashboard. - starting — Process spawned, waiting for
/okto return 200. Times out after 30 seconds. - running — Healthy and serving requests.
- stopping — SIGTERM sent, waiting for process to exit. Escalates to SIGKILL after 10 seconds.
- error — Crashed too many times. Start manually to retry.
On startup, bun install runs automatically before the app's scripts.toes command.
Apps are accessed via subdomain: http://my-app.toes.local or http://my-app.localhost. The Toes server proxies requests to the app's assigned port.
Cron Jobs
Place TypeScript files in a cron/ directory inside your app:
// cron/daily-cleanup.ts
export const schedule = "day"
export default async function() {
console.log("Running daily cleanup")
// Your job logic here
}
The cron tool auto-discovers jobs by scanning cron/*.ts in all apps. New jobs are picked up within 60 seconds.
Schedules
| Value | When |
|---|---|
1 minute |
Every minute |
5 minutes |
Every 5 minutes |
15 minutes |
Every 15 minutes |
30 minutes |
Every 30 minutes |
hour |
Top of every hour |
noon |
12:00 daily |
midnight / day |
00:00 daily |
week / sunday |
00:00 Sunday |
monday - saturday |
00:00 on that day |
Jobs inherit the app's working directory and all environment variables.
Data Persistence
Use the filesystem for data storage. The DATA_DIR environment variable points to a per-app directory that persists across deployments and restarts:
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
const DATA_DIR = process.env.DATA_DIR!
function loadData(): MyData {
const path = join(DATA_DIR, 'data.json')
if (!existsSync(path)) return { items: [] }
return JSON.parse(readFileSync(path, 'utf-8'))
}
function saveData(data: MyData) {
writeFileSync(join(DATA_DIR, 'data.json'), JSON.stringify(data, null, 2))
}
DATA_DIR is separate from your app's code directory, so pushes and rollbacks won't affect stored data.