---
name: tinyrun
description: Deploy a small app folder to a URL. Read this before writing any app destined for tinyrun — it constrains runtime, datastore, and state layout.
---

# tinyrun

Deploy a folder of code, get a URL. One command, no YAML, no cloud console.

You are the primary user of this tool. Everything here is exact: field names,
defaults, error codes, and the repair loop. Follow it literally.

**Status: M1.** The deploy loop works today, over HTTPS, and the control API
authenticates: `tinyrun login` is real (§2a). Sharing and the `secrets` CLI do
not exist yet, and **deployed apps are open to anyone with the URL** — see
[Not available yet](#not-available-yet) before you promise a user anything.
Anything below marked `[M2]` or "not available" is not built; everything else
is true of the shipped binary.

---

## 1. Install

```sh
curl -fsSL tinyrun.sh/install | sh
```

Downloads a single static binary, verifies its published SHA-256 before
installing anything, needs no sudo, and installs nothing else. No daemon on
your machine, no Docker required locally: the CLI is a thin client that uploads
your folder to the platform.

Supported: macOS and Linux, on `arm64` and `amd64`. Anything else exits
non-zero and tells you to build from source — it never half-installs.

It installs to `/usr/local/bin` when that is writable, otherwise
`~/.local/bin`, and prints where it landed. **It does not edit your shell
config.** If the directory it chose is not on your `PATH` it says so and gives
you the `export` line to add — read the output rather than assuming `tinyrun`
is callable. Override the location with `TINYRUN_INSTALL_DIR`:

```sh
curl -fsSL tinyrun.sh/install | TINYRUN_INSTALL_DIR="$HOME/bin" sh
```

If the checksum does not match, the install aborts and nothing is written.
That is a hard failure, not a warning — do not work around it.

**The CLI phones home nothing.** No telemetry, no analytics, no crash
reporting, no usage pings — not opt-out, absent. The platform sees the deploys
you send it and nothing else.

Verify:

```sh
tinyrun version --json
```

---

## 2. Every command

All output is machine-readable with `--json`. Use `--json` always.

```
tinyrun deploy [path]                # build and ship the folder (default ".")
tinyrun ls                           # list apps
tinyrun status <app>                 # state, runtime, current version, url
tinyrun logs <app> [-n lines] [--since D] [--grep RE2] [--json]
                                     # recent stdout/stderr (default 200 lines)
tinyrun doctor <app>                 # repair bundle: state, exit code, manifest, versions, logs
tinyrun rollback <app>               # restore the previously deployed version
tinyrun runs <app>                   # cron run history
tinyrun export <app> [-o file]       # tar.gz of code + /data
tinyrun rm <app> [--purge]           # soft-delete (restorable 7 days); --purge is immediate and irreversible
tinyrun restore <app>                # undo a soft-delete inside the window
tinyrun version
tinyrun help
```

Contract details you can rely on:

- `--json` works in any position, including after positional arguments.
- Flags may precede or follow positionals: `tinyrun logs app -n 5` and
  `tinyrun logs -n 5 app` are both accepted.
- Exit codes: `0` success, `1` command failed, `2` usage error (unknown
  command, bad flag, wrong argument count).
- In `--json` mode the payload goes to **stdout** — including error payloads,
  as `{"code","message","hint"}`. stdout is always parseable JSON; human text
  and diagnostics go to stderr. Never scrape stderr.
- `tinyrun logs -f` is **not available in M1**. Poll `tinyrun logs` instead.

Not yet implemented — these print an error saying so, they do not print
"unknown command": `share`, `secrets`, `tokens`.

**Every command needs a workspace token.** The API authenticates; an
unauthenticated call fails with `unauthorized`. Get one with `tinyrun login`
(§2a) or set `TINYRUN_TOKEN`. The token lives at `~/.config/tinyrun/token`
(mode 0600) — never print it, never paste it into a conversation, never commit
it. `tinyrun whoami` reports the account without revealing the token.

---

## 2a. Signing in (`tinyrun login`)

`tinyrun login` needs a human. You cannot finish it alone, and that is by
design — the terminal blocks while a person authenticates in a browser.

**Run it with no flags. Do not ask the user for their email address.**

```
$ tinyrun login
open this URL in a browser to finish signing in:

    https://api.tinyrun.sh/login#<one-time code>

waiting for you to finish in the browser… (Ctrl-C to cancel)
logged in as you@company.com
```

What you must do:

1. **Run `tinyrun login` with no arguments** (add `--no-browser` in a sandbox to
   skip the pointless browser attempt). It works with no stdin and no email.
2. **Show the user the printed URL and ask them to open it.** That URL is the
   whole handoff — it identifies this terminal, so whatever the user does there
   unblocks *your* command. Print it verbatim; the fragment after `#` matters.
3. The user finishes in the browser. What they see depends on that browser:
   - **not signed in** → a form asking for their email, then a sign-in link in
     their inbox, then they land back on the authorize step;
   - **already signed in** → an "authorize tinyrun CLI on this device?" page
     with one button.
4. Your command unblocks and prints `logged in as <email>`. The token is written
   to `~/.config/tinyrun/token` at mode 0600 (macOS:
   `~/Library/Application Support/tinyrun/token`).

The browser stays signed in for 30 days, so the second `tinyrun login` on the
same machine is usually one click with no email round trip.

`--email you@company.com` is an optional shortcut: it mails the link
immediately so the user skips typing their address. Use it **only** when the
user has already told you their address. Never prompt for one just to pass it —
the browser page asks, and it asks in the one place a human is actually present.

Rules:

- **Never print, echo, cat, or log the token file.** The CLI never prints the
  token, and neither should you. Print the email if you need to show identity.
- **Accounts are invite-only.** If sign-in never completes, the address probably
  has no account. Ask the operator to run `tinyrund invite <email>` on the box.
  The browser says "check your email" for known and unknown addresses alike, on
  purpose, so that message is not evidence the account exists.
- **Do not loop on failure.** Link requests and polls are rate-limited per email
  and per source address; a retry loop earns `rate_limited`.
- **The URL expires in 10 minutes.** If the user takes longer, the command exits
  and you run `tinyrun login` again for a fresh URL — the old one is dead.
- `tinyrun whoami` tells you whether you are already signed in. Prefer it over
  reading the token file.
- In CI or a runner with no human, set `TINYRUN_TOKEN` to a workspace token
  instead of running `login`.

---

## 3. The deploy loop

```sh
cd myapp
tinyrun deploy --json
```

On success:

```json
{
  "app": "myapp",
  "version": 3,
  "runtime": "python3.12",
  "url": "https://myapp.acme.tinyrun.sh",
  "image_ref": "tr/<app-id>:3"
}
```

What happens: the folder is packed as tar.gz and uploaded → runtime is detected
or read from `tinyrun.toml` → an image is built → a new container starts →
health check must pass → the route swaps to it → the previous container is
retired. **The swap is health-gated**: if the new version never answers, the
previous version keeps serving and the deploy fails with `healthcheck_failed`.

### Detection: what it infers without a manifest

Checked in this order; the first match wins.

| Found in the folder | Result |
|---|---|
| `tinyrun.toml` | Used as-is; nothing is inferred. |
| `Dockerfile` | `runtime = "dockerfile"`, your Dockerfile is built verbatim. |
| `requirements.txt` or `pyproject.toml` | `runtime = "python3.12"`, start command inferred (below). |
| `package.json` | `runtime = "node22"`, `npm start` if a `start` script exists, else `node <main>` if `main` names a file that exists. |
| `index.html` | `runtime = "static"`, served by Caddy. |
| none of the above | `needs_manifest` error. |

Python start inference needs **both** an entrypoint file and a recognizable
framework in the dependency file:

- entrypoint: `main.py` (preferred) or `app.py`. No other filename is looked at.
- framework, matched as a substring of `requirements.txt` + `pyproject.toml`:
  - `streamlit` → `streamlit run <file> --server.port $PORT --server.address 0.0.0.0`
  - `fastapi` or `uvicorn` → `uvicorn <module>:app --host 0.0.0.0 --port $PORT`
    (so the ASGI object **must** be named `app`)
  - `flask` → `flask --app <module> run --host 0.0.0.0 --port $PORT`

Anything else — Django, aiohttp, a plain script, a `src/` layout, an ASGI
object not named `app` — is not inferred. Write the manifest.

The app name defaults to the folder name, slugified: lowercased, every
character outside `[a-z0-9-]` becomes `-`, runs collapse, trimmed, capped at 40
chars. Name your folder something you want in the URL, or set `name` in the
manifest.

### The repair loop (do this, do not guess)

When detection cannot proceed, the error is:

```json
{
  "code": "needs_manifest",
  "message": "python app detected but start command could not be inferred",
  "hint": "name = \"myapp\"\nruntime = \"python3.12\"\nstart = \"\"  # <- fill this in\n"
}
```

**The `hint` field of a `needs_manifest` error is a ready-made `tinyrun.toml`.**
Write it to `tinyrun.toml`, fill in the `start` line, redeploy. That is the
entire loop — do not invent a manifest from memory when the platform just
handed you one.

If a manifest is present but wrong, you get `invalid_manifest` instead, and its
`message` lists every bad field as `field: problem` lines (all problems at
once, not just the first). Fix them all, redeploy.

### What is excluded from the build

Always excluded from the uploaded context: `.git`, `.gitignore`,
`node_modules`, `__pycache__`, `.venv`, `.env`, `.env.*`, `tinyrun.toml`,
`.tinyrunignore`. Symlinks and hardlinks are stripped by the packer and
**rejected** by the platform on extraction.

Add `.tinyrunignore` (dockerignore syntax, one pattern per line, `#` comments
allowed) to exclude more. If your folder has its own `.dockerignore`, it is
used instead of all of the above and taken as-is.

Caps: 100 MB uploaded (compressed), 500 MB unpacked. Over either →
`quota_exceeded`.

---

## 4. `tinyrun.toml`

Only needed when detection cannot infer the app, or when you want cron, extra
egress, a non-default port, or a healthcheck path. Full schema — these are all
the fields that exist:

```toml
name = "budget"                  # required
runtime = "python3.12"           # required
start = "uvicorn main:app --host 0.0.0.0 --port $PORT"
port = 8080                      # optional
healthcheck = "/healthz"         # optional

[secrets]                        # optional; names only, never values
require = ["SLACK_TOKEN"]

[egress]                         # optional; extra hostnames beyond the default allowlist
allow = ["api.notion.com"]

[[cron]]                         # optional, repeatable
schedule = "0 9 * * 1-5"
path = "/jobs/post-standup"
tz = "Europe/Istanbul"           # optional
```

### Field reference

| Field | Type | Required | Default | Rule |
|---|---|---|---|---|
| `name` | string | yes | — | Must match `^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$`: lowercase letters, digits, inner hyphens, 1–40 chars, no leading or trailing hyphen. It becomes the hostname label. |
| `runtime` | string | yes | — | Exactly one of `python3.12`, `node22`, `static`, `dockerfile`. |
| `start` | string | yes, except `static` and `dockerfile` | — | Shell command. Runs via `sh -c exec <start>`, so `$PORT` expands. No newlines or tabs. |
| `port` | int | no | `8080` | `0` (auto) or `1024`–`65535`. Privileged ports are rejected: the container is non-root. |
| `healthcheck` | string | no | `/` | Must start with `/`. |
| `secrets.require` | []string | no | `[]` | Names only. **Not enforced in M1** — see [Secrets](#7-secrets). |
| `egress.allow` | []string | no | `[]` | Bare hostnames: no scheme, no port, no path, no trailing dot, no IP addresses. One bad entry fails the whole deploy. |
| `cron[].schedule` | string | yes per block | — | Standard 5-field cron expression. Descriptors (`@daily`, `@every 1h`) also parse. |
| `cron[].path` | string | yes per block | — | Must start with `/`, no control characters. |
| `cron[].tz` | string | no | `UTC` | IANA zone name, e.g. `Europe/Istanbul`. |

There are no other keys. An unknown key is ignored silently — a typo like
`healthCheck` or `[[crons]]` will not error, it will just do nothing. Spell
them exactly as above.

`healthcheck` is used for two things with one definition: gating the deploy
swap, and deciding when a woken app is ready to serve.

### Runtimes

| `runtime` | Base image | Runs as | Notes |
|---|---|---|---|
| `python3.12` | `python:3.12-slim` | uid 1000 (`app`) | `requirements.txt` → `pip install -r`; else `pyproject.toml` → `pip install .`. `PYTHONUNBUFFERED=1` is set, so `print()` reaches `tinyrun logs`. |
| `node22` | `node:22-slim` | `node` user | `package-lock.json` → `npm ci`; else `npm install`. |
| `static` | `caddy:2-alpine` | uid 1000 (`app`) | Serves the folder from `/srv`. No `start` needed. |
| `dockerfile` | yours | see below | Escape hatch: your `Dockerfile` is built unchanged. |

**Do not choose a runtime outside this list.** Go, Bun, Ruby, Java, .NET and
PHP have no detection and no base image. If you need one, use the `dockerfile`
escape hatch — it works, it is just not polished.

Rules the `dockerfile` escape hatch must satisfy:

- The image must **not** run as root. `USER root`, `USER 0`, or an image whose
  config demands uid 0 is refused outright. Add a non-root `USER`; an image
  with no `USER` at all is forced to uid 1000, which then must be able to read
  your app files.
- The rootfs is **read-only** at runtime. Write only to `/data` (persistent) and
  `/tmp` (tmpfs, 64 MB, wiped on stop).
- All Linux capabilities are dropped. A binary carrying file capabilities will
  fail `execve` with `EPERM`. (This is why the `static` recipe runs
  `setcap -r` on Caddy.) Bind an unprivileged port; do not rely on
  `cap_net_bind_service`.
- Listen on `$PORT`, which is injected into the environment.

### `$PORT` is not optional

Your app must listen on the port in `$PORT`, on `0.0.0.0` (not `127.0.0.1` —
the platform reaches you across the container boundary). `$PORT` equals
`port` from the manifest, or `8080`. Binding a hardcoded different port is the
single most common cause of `healthcheck_failed`.

---

## 5. Datastore: SQLite only. Do not reach for Postgres.

**v1 provides no Postgres, no MySQL, no Redis, no managed database of any
kind.** There is no `DATABASE_URL` to read. If you write an app that expects
one, it will start, fail to connect, and the deploy will fail its healthcheck.

Use SQLite in a file under `/data`:

```python
import sqlite3
db = sqlite3.connect("/data/app.db")
```

```js
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync("/data/app.db");
```

The reason, so you can judge edge cases yourself: apps here sleep when nobody
is using them and wake on the next request, and the whole app is one container
plus one directory. A network database would keep RAM resident and turn a
one-folder app into two services with a connection string to misconfigure.
SQLite files in `/data` are also what the platform replicates continuously
(Litestream) and what `tinyrun export` hands back. A single writer with WAL
mode handles the traffic a shared team tool actually gets by a wide margin.

Practical guidance:

- Enable WAL: `PRAGMA journal_mode=WAL`.
- Keep the database in `/data`, never in the app directory (read-only rootfs,
  and it would be wiped on every deploy anyway).
- Do not run migrations that you cannot roll forward from — see the rollback
  warning in [`/data` semantics](#6-data-semantics).
- Queues, caches, and sessions all belong in the same SQLite file. Adding Redis
  is not an option.

---

## 6. `/data` semantics

- `/data` is a persistent volume, one per app. It is the **only** writable
  location that survives anything. The rest of the filesystem is read-only,
  except `/tmp` (tmpfs, 64 MB, gone when the app stops).
- `/data` survives: redeploys, sleep/wake, rollbacks, restarts, `tinyrun rm`
  (for 7 days).
- `/data` does **not** survive `tinyrun rm --purge`. That is immediate and
  irreversible.
- **`tinyrun rollback` does NOT rewind `/data`.** The code goes back; the data
  does not. If v4 migrated the schema and you roll back to v3, v3 meets the
  migrated database. Design migrations so the previous version can still read
  what the new one wrote, or you have painted yourself into a corner that
  rollback cannot undo. (`rollback --with-data` does not exist yet.)
- Everything in `/data` is yours, not just SQLite. Uploaded files, generated
  reports, caches — the platform carries all of it.
- `tinyrun export <app>` writes a `tar.gz` containing **both** the code and the
  data: `src.tar.gz` (the deploy context that produced the current version) and
  `data/…` (a copy of the live `/data` tree). That is the whole app, portable.

---

## 7. Secrets

Rules that will not change:

- **Never put a secret in the repo.** Not in `tinyrun.toml`, not in a committed
  `.env`, not in source. `tinyrun.toml` carries secret *names* only.
- Declare what the app needs:

  ```toml
  [secrets]
  require = ["SLACK_TOKEN", "OPENAI_API_KEY"]
  ```

- Values are set out of band by a human, never by you and never through a file
  in the repo.
- Read them as **files**, not environment variables. The delivery contract is
  a per-app tmpfs: `/run/secrets/<NAME>`, mode 0400, owned by the app's user.
  Environment variables leak — they are readable via `/proc/<pid>/environ`,
  inherited by every child process, and printed by dying apps in tracebacks.
  Write your app to read `/run/secrets/SLACK_TOKEN` (falling back to
  `os.environ` is fine as a local-dev convenience).

**Not available in M1, be honest with the user about this:**
`tinyrun secrets set` does not exist yet, `[secrets].require` is parsed and
validated but **never enforced** (a deploy missing a required secret succeeds
and the app fails at runtime), and no secret is delivered to the container by
any mechanism. If your app needs a secret today, it cannot get one. Say so
rather than shipping an app that silently cannot work.

Also: never echo a CLI token or a magic-link URL into the conversation, a
commit, a log line, or an error message. Chat transcripts are where
credentials go to die. The CLI's token will live in `~/.config/tinyrun/token`
(0600) and must never be moved into an environment variable or printed.

---

## 8. Egress: outbound network is allowlisted

Containers can reach only allowlisted hostnames, over ports 80 and 443 only.
Direct-to-IP connections are dropped. Port 25 is blocked. RFC1918 and
link-local addresses are always blocked, so apps cannot reach each other, the
control plane, or cloud metadata endpoints. DNS goes through the platform
resolver only.

Allowed by default for every app:

```
api.anthropic.com          api.openai.com
api.slack.com              hooks.slack.com
www.googleapis.com         oauth2.googleapis.com
accounts.google.com        generativelanguage.googleapis.com
api.github.com             raw.githubusercontent.com
codeload.github.com        objects.githubusercontent.com
api.stripe.com             api.sendgrid.com
api.resend.com             api.twilio.com
api.notion.com             api.airtable.com
api.linear.app             api.cloudflare.com
sentry.io
```

Anything else your app calls at runtime must be declared:

```toml
[egress]
allow = ["api.example.com", "files.example.com"]
```

Extras are auto-approved (no human review) and logged. Notes that matter:

- Declare the **exact API hostname**, not the apex. `example.com` is a
  redirector; `api.example.com` is a destination.
- Package registries (`registry.npmjs.org`, `pypi.org`, …) are reachable during
  the build but deliberately **not** at runtime. Do not write an app that
  `pip install`s or `npm install`s at startup.
- A hostname with a scheme, port, path, trailing dot, or an IP literal is
  rejected — the whole deploy fails, not just that entry.
- Outbound calls made only from a `[[cron]]` job still need to be declared.

---

## 9. Sleep and wake

- An app with no activity for **15 minutes** is stopped and its RAM is freed.
- The next request wakes it. Cold start is roughly 550 ms for static and
  Python, ~1.7 s for Streamlit (measured p95 on the beta box).
- Websockets count as activity. An app held awake only by a websocket is
  stopped after **2 hours** with no HTTP request and no websocket **data**
  frame from the browser; protocol ping/pong keepalives do not reset that
  clock. An app-level heartbeat sent as an ordinary message does count, so a
  tab nobody is watching can hold the app awake until it is closed.
- Consequences for how you write the app:
  - **In-process state does not survive.** Module-level caches, in-memory
    sessions, `setInterval` timers, and background threads are all lost when
    the app sleeps. Persist to `/data`.
  - **Do not write your own scheduler.** A `while True: sleep(3600)` loop stops
    running the moment the app sleeps, and keeping the app awake to run it is
    exactly what the platform charges nothing to avoid. Use `[[cron]]`.
  - Startup must be cheap: it runs on every wake, and a slow start is what the
    user sees as a slow page.

Quotas per app: 256 MB RAM, 0.5 vCPU, 128 processes, 30 s request timeout,
and **1 GiB of disk on `/data`** (the operator sets the disk figure per box).
The CPU limit is usually the binding one — heavy imports at startup cost real
wake latency.

`/data` is a filesystem of its own, sized to the quota. Two consequences worth
designing around:

- **Past the cap, writes fail with `ENOSPC`** ("No space left on device"), and
  SQLite reports `SQLITE_FULL`. It is your app's volume that is full, not the
  box — no other app is affected, and no operator action frees it. Delete files
  under `/data` or store less.
- **Check it before you debug it.** `tinyrun doctor <app> --json` carries a
  `disk` object with `quota_bytes`, `used_bytes`, and `percent_used`. An
  unexplained write failure on a healthy app is this, most of the time.

So: keep uploads bounded, rotate anything you append to, and do not park a
dataset in `/data`.

---

## 10. Cron

The platform wakes the app and issues a **`GET`** to the declared path. Apps
cannot wake themselves, so this is the only way to run scheduled work.

```toml
[[cron]]
schedule = "0 9 * * 1-5"
path = "/jobs/post-standup"
tz = "Europe/Istanbul"
```

- Method is `GET`, always. Give the endpoint a route that accepts `GET`.
- `schedule` is standard 5-field cron. Zone is **UTC** unless `tz` is set.
- Multiple `[[cron]]` blocks are allowed, including two schedules hitting the
  same path.
- A run is bounded by the **30 s** timeout. Work that takes longer must be
  chunked across ticks — a hung endpoint cannot hold the app awake.
- **No retries.** A failed run is recorded; the next tick is the retry.
- **No overlap.** A job still running when its next tick arrives is skipped and
  recorded, never stacked.
- A response status `>= 400` is recorded as a failure. Return 2xx/3xx on
  success.
- Changes take effect on the next redeploy (the scheduler re-reads manifests
  within a minute).
- `tinyrun runs <app> --json` shows history: `schedule`, `path`, `started_at`,
  `status_code`, `error`.

### Platform call headers — read the warning

A scheduled request carries two headers:

```
X-Tinyrun-Service-Token: <opaque value>
X-Tinyrun-Cron: <schedule-id>
```

**In M1 the token is UNAUTHENTICATED. Do not use it for authorization.**
It is a fresh random value per run that nothing validates; any caller that can
reach your port can set the same header. Treat it as a hint that a request is
probably platform cron, never as proof. Real per-app tokens bound to the app
and path arrive in M2.

If a cron endpoint must not be triggered by a stranger, the endpoint must be
harmless to trigger — idempotent, and safe to run twice. Do not build an
endpoint that spends money or sends mail while relying on this header to gate
it.

**`X-Tinyrun-Cron` is not a credential either.** It says *which* job fired, not
that the platform fired it. Same rule: route on it, never authorize on it.

#### `X-Tinyrun-Cron`: which job fired

Use it when one handler serves several `[[cron]]` blocks. The value is stable —
the same block sends the same id on every fire, across platform restarts and
across redeploys that leave the block unchanged — so it is safe to compare
against a constant in your code.

The id is `<path-slug>-<digest>`, derived from **that block's `schedule`, `tz`,
and `path` only**:

| `[[cron]]` block | `X-Tinyrun-Cron` |
|---|---|
| `schedule = "0 9 * * 1-5"`, `path = "/jobs/post-standup"` | `jobs-post-standup-e8d31798` |
| same, plus `tz = "Europe/Istanbul"` | `jobs-post-standup-fd66a4ac` |
| `schedule = "@daily"`, `path = "/sync"` | `sync-05c32a32` |
| `schedule = "*/5 * * * *"`, `path = "/"` | `root-08f0785f` |

Consequences worth knowing:

- **Two blocks on the same path get different ids** (that is the point — the
  digest covers the schedule and zone, not just the path).
- **Editing a block's `schedule`, `tz`, or `path` changes its id.** It is the
  identity of a scheduled job, not a name you assign. Change the schedule and
  you get a new id; branch on it defensively (`else` → log and ignore) rather
  than assuming a fixed set forever.
- **Changing `catch_up` or `catch_up_window` does not change the id.** When a
  late tick may fire is not part of what job it is.
- Values are always `[a-z0-9-]`, so they are safe to log, compare, and use as a
  map key or metric label.

Don't hardcode the ids by hand — read them once from a real request (log the
header on first run) and keep them as constants, or just switch on the request
path if one path means one job.

Scheduled requests reach the container directly rather than through the proxy,
so they bypass the (future) auth gate. That is also why the token cannot yet be
trusted.

---

## 11. Error codes

Every failure, from the CLI and the API alike, is
`{"code": …, "message": …, "hint": …}`. Branch on `code`; `message` and `hint`
are human-readable and may change wording. Codes never change.

### Failures carry their own evidence

A failure payload may also carry three **optional** fields. They are additive:
if you already parse `code`/`message`/`hint`, nothing you do breaks — but
reading them is the difference between fixing the app in one call and guessing.

| field | type | present when |
|---|---|---|
| `stage` | string | the pipeline step that failed: `pack`, `detect`, `build`, `start`, `health`, `route` |
| `logs` | array of strings | the relevant excerpt — build output tail, container log tail, or the file listing detection saw. At most 40 lines, each truncated at ~500 chars, always redacted |
| `probe` | object | health failures only: `{url, last_status, last_error, waited}`. `last_status: 0` means nothing answered at all |

**If `logs` is present, read it before running any other command — it usually
contains the fix.**

A real `healthcheck_failed` payload from a Python app that crashed on import:

```json
{
  "code": "healthcheck_failed",
  "message": "app did not answer GET / with a status below 500 within 30s",
  "hint": "nothing answered on the app's port — read the attached logs: the app either crashed at startup or bound 127.0.0.1 instead of 0.0.0.0:$PORT. Fix and redeploy",
  "stage": "health",
  "logs": [
    "starting worker",
    "Traceback (most recent call last):",
    "  File \"/app/main.py\", line 4, in <module>",
    "    import nonexistent_module_xyz",
    "ModuleNotFoundError: No module named 'nonexistent_module_xyz'"
  ],
  "probe": { "url": "/", "last_status": 0, "last_error": "connection refused", "waited": "30.1s" }
}
```

Everything needed to fix it is in that one response: the stage says the app was
started but never answered, `probe.last_status: 0` says nothing was listening,
and `logs` names the missing module. Add it to `requirements.txt` and redeploy.

**Secret values are stripped from `logs` before you ever see them** — a redacted
value appears as `[REDACTED]`. If the platform cannot read an app's secrets to
scrub them, it omits `logs` entirely rather than risk leaking one, so an absent
`logs` on a health failure means `tinyrun doctor <app> --json` is your next step.

| `code` | Cause | Your next action |
|---|---|---|
| `needs_manifest` | No runtime could be detected, or a runtime was detected but the start command could not be inferred. | **`hint` contains a suggested `tinyrun.toml`.** Write it out, fill in `start`, redeploy. `logs` lists what the folder actually contained, so you can see why detection missed. |
| `invalid_manifest` | `tinyrun.toml` failed to parse or validate. | `message` lists every bad field as `field: problem`. Fix all of them, redeploy. |
| `invalid_request` | Malformed invocation or upload: unknown command, bad flag, missing/extra argument, non-tar.gz body, symlink or traversal path in the context. | A usage mistake on your side. Read `hint`, correct the command or the folder. Do not retry unchanged. |
| `unsupported_runtime` | `runtime` is not one of `python3.12`, `node22`, `static`, `dockerfile`. | Set a supported runtime, or ship a `Dockerfile` and use `runtime = "dockerfile"`. |
| `build_failed` | The image build failed (dependency install, syntax error, missing file), or `runtime = "dockerfile"` with no `Dockerfile` present. | `stage` is `build` and `logs` carries the build output tail — the failing step is in the last lines. Fix the code or dependency, redeploy. Do not retry unchanged. |
| `healthcheck_failed` | The new container did not answer `GET <healthcheck>` with a status below 500 within 30 s. | The previous version is still serving. **Read `logs` and `probe` in the payload — do not run `tinyrun logs`, the lines are already there.** Almost always: not listening on `$PORT`, bound to `127.0.0.1` instead of `0.0.0.0`, crashed at startup, or the healthcheck path 500s. |
| `app_not_found` | No app by that name in this workspace — including an app a `tinyrun rm` removed while your deploy or rollback was still running. That deploy writes nothing: no version is recorded and nothing is promoted. | `tinyrun ls` to see real names. If it was removed within 7 days, `tinyrun restore <app>` and then deploy again; the app comes back on the version that was serving when `rm` ran. |
| `no_previous_version` | `rollback` on an app with only one deploy. | Nothing to roll back to. Fix forward and redeploy. |
| `name_taken` | The name belongs to a soft-deleted app. | `tinyrun restore <name>` to reclaim it, or pick a different `name`. |
| `quota_exceeded` | Build context over 100 MB compressed or 500 MB unpacked. | Add a `.tinyrunignore`; drop datasets, build outputs, and virtualenvs from the folder. |
| `secret_missing` | A declared secret has no value. | Reserved; **not emitted in M1** (`[secrets].require` is not enforced yet). |
| `unauthorized` | Missing, invalid, revoked, or expired token — or a token for a different workspace. All four are one response on purpose. | Run `tinyrun login`, or set `TINYRUN_TOKEN`. Check `tinyrun whoami`. |
| `rate_limited` | Too many sign-in links or poll attempts, per email and per source address. | Wait and retry; do not loop. |
| `internal` | Platform-side failure. | Retry once. If it persists, `tinyrun doctor <app> --json` and report it — this one is not your bug. |

Useful pattern: on any deploy failure, `tinyrun doctor <app> --json` returns
`{app, state, exit_code, manifest, logs, versions, disk}` — the whole repair
context in one call, no log scraping.

`state` is one of `running`, `stopped`, `absent`.

`disk` reports the app's `/data` against its quota (§9) and is present only on
boxes that cap disk:

```json
"disk": {
  "configured": true, "enforced": true,
  "quota_bytes": 1073741824, "used_bytes": 1073737728,
  "available_bytes": 0, "percent_used": 100,
  "note": "this app's /data is full. Writes fail with ENOSPC…"
}
```

Read it first whenever writes fail for no visible reason. `enforced: false` is
the one value that is not your bug — it means the platform did not apply the
cap and you should report it rather than change your app.

---

## 11a. `tinyrun logs` as a debugging instrument

Four flags compose, so you ask one precise question instead of pulling 200
lines and filtering them yourself:

| flag | effect |
|---|---|
| `-n N` | keep at most N lines (default 200) |
| `--since D` | only lines newer than a Go duration: `30s`, `10m`, `2h` |
| `--grep RE2` | keep only lines matching an [RE2](https://golang.org/s/re2syntax) pattern |
| `--json` | one JSON object per line: `{ts, stream, line}` |

`-n` and `--since` combine — whichever cuts first wins. `--grep` is applied
before `-n`, so `--grep Error -n 5` means *the last five errors*, not the errors
that happen to fall in the last five lines.

The recipe worth remembering:

```
tinyrun logs app --since 10m --grep "Error|Traceback" --json
```

`--json` emits **line-delimited JSON, not an array** — parse it one line at a
time:

```json
{"ts":"2026-07-25T15:39:04.509822Z","stream":"stdout","line":"starting worker"}
{"ts":"2026-07-25T15:39:04.509985Z","stream":"stderr","line":"ModuleNotFoundError: No module named 'x'"}
```

`ts` is RFC3339 and `stream` is `stdout` or `stderr`. **A field the runtime
cannot supply is omitted, never invented** — if you see no `ts`, the runtime
genuinely could not report one. Never assume a missing field means "now".

Human output is byte-identical to the old behaviour when you pass none of these
flags, and redaction applies here exactly as it does to error payloads.

---

## 11b. The self-fix loop

This is the loop you run. No human in it.

**1. Deploy.**

```
tinyrun deploy ./myapp --json
```

**2. On success**, confirm it is actually live before you report done:

```
curl -fsS -o /dev/null -w '%{http_code}' <url>
```

**3. On failure, read the payload — do not run another command yet.**

- Read `.stage` first. It tells you *where* it broke:
  - `detect` → no runtime inferred. `hint` is a ready-made `tinyrun.toml`;
    `logs` shows what the folder actually contained.
  - `build` → `logs` is the build output tail. The failing step is in the last
    lines; it is almost always a missing or misspelled dependency.
  - `start` / `health` → `logs` is the container's output and `probe` says what
    the healthcheck saw.
- Read `.logs`. **This usually contains the exact fix.** A `ModuleNotFoundError`
  names the package to add. A `Traceback` names the file and line.
- For `health` failures read `.probe`:
  - `last_status: 0` → nothing was listening. The app crashed at startup, or it
    bound `127.0.0.1` instead of `0.0.0.0:$PORT`.
  - `last_status: 500` (or any 5xx) → the app is up but its healthcheck path
    errors. Fix the handler, or point `healthcheck` at a path that works.

**4. Patch the app** based on what you just read. Change the code or the
manifest — not the deploy command.

**5. Redeploy.** Same command as step 1.

**6. Confirm live.** Repeat step 2. Only then report success.

Two rules that keep this loop honest:

- **Do not retry an unchanged deploy.** `build_failed` and `healthcheck_failed`
  are deterministic; the same input fails the same way. Change something first.
- **Do not run `tinyrun logs` when the failure payload already carried `logs`.**
  It costs a round-trip for lines you already have. Reach for `tinyrun logs`
  (or `tinyrun doctor`) when an app misbehaves *after* a successful deploy, or
  when `logs` was omitted from the payload.

---

## 12. Worked examples

### Python API with persistent state

`main.py`:

```python
import os, sqlite3
from fastapi import FastAPI

app = FastAPI()
db = sqlite3.connect("/data/app.db", check_same_thread=False)
db.execute("PRAGMA journal_mode=WAL")
db.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)")

@app.get("/healthz")
def healthz():
    return {"ok": True}

@app.get("/")
def index():
    rows = db.execute("SELECT body FROM notes ORDER BY id DESC LIMIT 50").fetchall()
    return {"notes": [r[0] for r in rows]}
```

`requirements.txt`:

```
fastapi
uvicorn
```

No manifest needed — detection infers
`uvicorn main:app --host 0.0.0.0 --port $PORT`. Add one only for the
healthcheck path:

```toml
name = "notes"
runtime = "python3.12"
start = "uvicorn main:app --host 0.0.0.0 --port $PORT"
healthcheck = "/healthz"
```

```sh
tinyrun deploy --json
```

### Node app with a daily job

```toml
name = "standup"
runtime = "node22"
start = "node server.js"
healthcheck = "/healthz"

[egress]
allow = ["api.notion.com"]

[[cron]]
schedule = "0 9 * * 1-5"
path = "/jobs/standup"
tz = "Europe/Istanbul"
```

```js
import http from "node:http";
const port = process.env.PORT || 8080;

http.createServer(async (req, res) => {
  if (req.url === "/healthz") return res.end("ok");
  if (req.url === "/jobs/standup") {
    await postStandup();          // idempotent: cron may fire it twice
    return res.end("done");
  }
  res.end("standup bot");
}).listen(port, "0.0.0.0");
```

### Static site

A folder with `index.html` needs nothing at all:

```sh
tinyrun deploy ./site --json
```

---

## 13. Not available yet

State this plainly to users instead of building around it. None of the
following exists in the shipped binary:

- **Sharing.** No `share` or `tokens` yet. `login` and `whoami` work (§2a): the
  control API authenticates, so deploying needs a workspace token. **Deployed
  apps themselves are still unauthenticated** — anyone with the URL reaches
  them. Do not deploy anything sensitive.
- **The `secrets` CLI.** No `tinyrun secrets set`, and `[secrets].require` is
  not enforced. Delivery itself is built: a stored secret is mounted as a file
  in a per-app tmpfs at `/run/secrets/<NAME>`, never as an environment
  variable. There is simply no shipped command to store one yet.
- **`tinyrun logs -f`.** Poll instead.
- **A trustworthy service token.** `X-Tinyrun-Service-Token` and
  `X-Tinyrun-Cron` are both sent, but neither is authenticated — see §10.
- **Cron `catch_up` / `catch_up_window`** manifest keys, and the `missed` run
  status. A tick missed while the platform was down is simply not fired.
- **A `status` field on `tinyrun runs` output.** Runs report `status_code` and
  `error`; use `status_code == 0` plus a non-empty `error` to detect a run that
  never reached the app.
- **`rollback --with-data`.** Rollback never touches `/data`.
- **Raising your own disk quota.** The cap is set per box by the operator; an
  app cannot ask for more. See §9 for what hitting it looks like.
- **The web dashboard.** A read-only workspace and per-app view exists in the
  daemon, but no browser sign-in is wired to it yet, so there is nothing a
  person can open today. Keep using `tinyrun ls`, `tinyrun status <app>`, and
  `tinyrun runs <app>`: the dashboard shows exactly what those already report,
  and it will never be a place to deploy from.
- **Custom domains** and HTTPS on them. App URLs under `tinyrun.sh` itself are
  already HTTPS with a real certificate — plain HTTP redirects.
- **Usage logs, always-on apps, Postgres, and an MCP server.**

Runtimes that will not be added: JVM, Rails, .NET. Go and Bun are candidates.
Until then, `runtime = "dockerfile"`.

---

## 14. Checklist before you call a deploy done

1. Listens on `$PORT`, bound to `0.0.0.0`.
2. Persistent state is in a file under `/data`, and it is SQLite — not
   Postgres, not Redis, not an in-memory dict.
3. Nothing writes outside `/data` and `/tmp`.
4. Scheduled work is a `[[cron]]` block with a `GET` route, not a background
   loop; the endpoint is idempotent.
5. Every outbound hostname the app calls is either on the default allowlist or
   in `[egress].allow`.
6. No secret is in the repo. No token or magic link has been echoed anywhere.
7. `healthcheck` returns quickly and does not depend on anything that could be
   down.
8. `tinyrun deploy --json` exited `0` and the printed `url` answers.
