toes/docs/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.

Tip: Add .toes to 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:

  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.