21 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
- App Lifecycle
- Cron Jobs
- Data Persistence
Quick Start
# Install the CLI
curl -fsSL http://toes.local/install | bash
# Create a new app
toes new my-app
# Enter the directory, install deps, and develop locally
cd my-app
bun install
bun dev
# Push to the server
toes push
# Open in browser
toes open
Your app is now running at http://my-app.toes.local.
Tip: Add
.toesto your.gitignore. This file tracks local sync state and shouldn't be committed.
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/
.npmrc # Points to the private registry
.toesignore # Files to exclude from sync (like .gitignore)
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, 'current')
// Read files from appPath...
})
Always go through the current symlink — never access version directories directly.
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
Creates the app locally, then pushes it to the server. 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> — Download an app from the server to your local machine.
toes get my-app # Creates ./my-app/ with all files
cd my-app
bun install
bun dev # Develop locally
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).
Syncing Code
Toes uses a manifest-based sync protocol. Each file is tracked by SHA-256 hash. The server stores versioned snapshots with timestamps.
toes push — Push local changes to the server.
toes push # Push changes (fails if server changed)
toes push --force # Overwrite server changes
Creates a new version on the server, uploads changed files, deletes removed files, then activates the new version. The app auto-restarts.
toes pull — Pull changes from the server.
toes pull # Pull changes (fails if you have local changes)
toes pull --force # Overwrite local changes
toes status — Show what would be pushed or pulled.
toes status
# Changes to push:
# * index.tsx
# + new-file.ts
# - removed-file.ts
toes diff — Show a line-by-line diff of changed files.
toes sync — Watch for changes and sync bidirectionally in real-time. Useful during development when editing on the server.
toes clean — Remove local files that don't exist on the server.
toes clean # Interactive confirmation
toes clean --force # No confirmation
toes clean --dry-run # Show what would be removed
toes stash — Stash local changes (like git stash).
toes stash # Save local changes
toes stash pop # Restore stashed changes
toes stash list # List all stashes
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 push creates a timestamped version. The server keeps the last 5 versions.
toes versions [name] — List deployed versions.
toes versions my-app
# Versions for my-app:
#
# → 20260219-143022 2/19/2026, 2:30:22 PM (current)
# 20260218-091500 2/18/2026, 9:15:00 AM
# 20260217-160845 2/17/2026, 4:08:45 PM
toes history [name] — Show file changes between versions.
toes rollback [name] — Rollback to a previous version.
toes rollback my-app # Interactive version picker
toes rollback my-app -v 20260218-091500 # Rollback to specific version
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://abc123.trycloudflare.com
toes unshare [name] — Stop sharing an app.
Configuration
toes config — Show the current server URL and sync state.
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:3000). |
TOES_DIR |
Path to the Toes config directory. |
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'))
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.