toes/GUIDE.md

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

# 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.


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:

  1. package.json with a scripts.toes entry
  2. index.tsx that exports app.defaults
  3. A GET /ok route 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:

  1. Set "tool": true in package.json
  2. Include <ToolScript /> in the HTML body
  3. Prepend baseStyles to 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.
  • baseStyles sets the body background to match the dashboard theme.
  • prettyHTML: false is 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.json or scripts.toes. Fix the config and start manually.
  • stopped — Not running. Start with toes start or the dashboard.
  • starting — Process spawned, waiting for /ok to 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.