Skip to main content
Your agent reads this page with tesser help setup when it puts a repo on Tesser, so it is written to the agent. What it leaves behind is the repo’s own skill at .agents/skills/tesser/, which every agent after it reads instead. tesser --help and tesser service --help are the reference for the tool and the manifest format. Read both first and trust them over this guide. You are building for the next agents. Each arrives in a fresh worktree with a task and no human to ask, and reads only the repo’s tesser skill, never this guide or tesser’s. Most of their tasks need one of three things, cheapest first, and the repo skill gives each as one command:
  1. A focused check: a test file or a typecheck, on the worktree’s workbench with nothing running (tesser exec -- <cmd>).
  2. The running app, no code change: open a page, sign in, create a record. This needs zero personal boxes: it runs against the org’s shared instances, which you pin.
  3. The running app with a change: tesser dev only the services the change touches; every other dep stays on its shared instance.
A cold personal box takes minutes to tens of minutes; a shared instance is already up. What makes path 2 instant is shared instances of the whole app, built once by you. What makes paths 2 and 3 usable is a sign-in an agent gets in one command.

Order of work

Most of setup is waiting on cold boxes, installs and migrations. Never wait on one thing while something else could run:
  • Anything that can take over 30 s runs in the background (your tool’s background option): tesser dev, make, snapshot create, migrations, installs, test runs, the focused check you time for the repo skill. Its exit arrives as a notification on a later tool call. Never write a foreground loop that sleeps on a file, log or task output.
  • Keep a queue of work that does not need a running box, and work it while things boot: the other manifests, run scripts, login.sh, create.sh, db.sh, up.sh, the repo skill, reading the auth code.
  • Block only when the queue is empty, on the one thing you need next: tesser wait <box> in the foreground, at most 9 minutes per call. A minute after each launch, read tesser logs <box>: a box that crash-loops on a bad env value or a missing package shows it there long before wait gives up. Never end your turn to wait: background tasks and their notifications die with it.
  1. In the first few minutes, write every manifest you can from the repo’s own config and start them all at once, in the background. Infra holds every database, queue, cache and search container the app’s migrations or boot touch (read the compose files and the settings for each), because adding one later re-pins infra onto an empty disk. After that, rerun the migrate step from an app box (tesser exec <box> -- bash .agents/skills/tesser/app-run.sh migrate): pinning the app again at the same sha does nothing while it is healthy. Start infra with tesser make <infra> --ensure-running <full sha>, each app box with its first cold tesser dev. Fix a manifest and restart only that box.
  2. While they boot, write the dev user, login.sh and create.sh against the code, the repo skill, and up.sh. Once infra and your app box are up, migrate and seed once from it in the background (section 1, One service migrates). Test each script the moment its box is up.
  3. When login.sh and create.sh work against your own app box, the box config is final: pin every app service the main page needs, frontend included, at your HEAD (start from main, so HEAD’s migrations are main’s), and tesser snapshot create --sha <HEAD>, all in the background, at once. Pinning from the worktree whose own box of that service works hands that warm box over as the shared instance; your next tesser dev of it starts a new box. Pin each once: a re-pin, or a pin with no box of yours to hand over, is a new box, provisioned cold.
  4. As soon as your own box passes the browser check, tell the user the URL and how to sign in. Then prove path 2 from a fresh worktree (section 5).

1. Up

Start from the repo’s own dev and agent tooling (README, docker-compose*, Makefile, package.json scripts, .env.example, .env.agent, .devcontainer, devservices, CI) and override only hosts, ports and URLs.
  • One .toml per process in the repo’s tesser skill directory, the first that fits:
    1. .agents/skills/tesser/, when the repo has .agents/skills or neither. Link .claude/skills -> ../.agents/skills, or only .claude/skills/tesser -> ../../.agents/skills/tesser when .claude/skills already holds other skills.
    2. .claude/skills/tesser/, when the repo keeps its skills only in .claude/skills. Link .agents/skills/tesser -> ../../.claude/skills/tesser.
    A link is not a second manifest directory. Paths below say .agents/skills/tesser; use yours. Templates under Templates below.
  • Pin every port. [run] dev points at a run script that execs the process. [health] with a real path and a timeout sized for the cold first install and compile. A port answering is not health.
  • Lean setup. Install only what the dev processes import: in a monorepo, only those workspaces (pnpm --filter=<app>... install, yarn workspaces focus). Runtimes come prebuilt into $HOME (the image’s fnm, uv, mise with ruby.compile=false); a setup that compiles a runtime from source is a bug. Skip native builds the dev server never loads. Never invent a secret the code validates: copy it from the repo’s example env. Time each step; cut any over 2 minutes the page does not need.
  • Declare what setup reads in [run] setup_inputs: lockfiles, every package.json (**/package.json: globs match from the repo root), patches, .npmrc, the setup script itself, and the sources of anything setup builds. tesser skips setup when none changed since it last succeeded on that disk, a snapshot’s disk included; tesser dev --rerun-setup runs it anyway. A skip skips all of setup, so a build input left out means a stale build. Inside setup, gate each install or build on a hash of its files’ contents (git ls-files -z -- <files> | xargs -0 sha1sum | sha1sum, stamp in ~/.cache), never git’s index or mtimes, so a rerun redoes only what changed.
  • Slow state is shared. Databases, queues and search run in one org shared instance, pinned first, never in a container on an app box, whose disk a re-pin replaces with a new box. If the repo’s provisioning puts its databases on the app machine (an all-in-one dev VM, tools/provision, bin/setup with system services), do not split it: pin that whole service. Shared instances that share state (one redis, one set of queues) are pinned at the same sha.
  • One service migrates. For each database, the app service that owns its schema (usually the API) migrates and seeds it from its run script when $TESSER_SLOT is pinned, which only shared instances get (template below). The step runs on every start of the shared instance, and a run script that exits takes the instance down until someone re-pins it. So it waits for the database, uses the deploy form of migrate (prisma migrate deploy, drizzle-kit migrate, rails db:migrate, alembic upgrade head, manage.py migrate --noinput), never migrate dev, db push, db:reset, db:setup or anything that prompts, and seeds only when a row only the seed makes is missing. Before the app is pinned, run it once from your own app box with the run script’s env (tesser exec <box> -- bash .agents/skills/tesser/app-run.sh migrate) so login.sh has data; the pin’s run then finds nothing to do.
  • Nobody else touches the shared schema: not workers or frontends, not task agents, not [run] setup (it also runs on every personal box and on the snapshot builder). Pin only commits whose migrations are on main; a branch with its own migrations uses its own database (below).
  • Worktrees stay apart inside shared state. Every worktree signs in to its own tenant (section 2). Redis key prefixes, queue names and bucket prefixes carry $TESSER_SLOT. A worktree whose change adds migrations gets its own database copied from the shared one (pg_dump | psql into a new database; CREATE DATABASE … TEMPLATE refuses while the shared app holds connections), names it in its .env.tesser, and applies only its new migrations there (app-run.sh migrate); never a migrate from empty, and never against the shared one.
  • What survives. Snapshots and pool boxes carry /home/ubuntu and docker’s data, not /usr, /opt or apt packages. System packages go in the manifest’s apt = [...]: tesser caches their .debs in $HOME and installs them before setup on every start, offline on a box from a snapshot. Give every manifest the same apt list: a service whose list differs reruns setup on boxes from a snapshot. Setup writes only under $HOME and docker. A write under /etc, /usr, /opt or /var/lib, a process left running, or a container without restart: unless-stopped is logged and makes setup rerun after every reboot and on every box from a snapshot: a warm-up stops everything it starts, and infra setup only pulls images. setup also runs on the snapshot builder, which has no box id or address: anything per box goes in the run script. A database can outlive the files it points at: when the app supports object storage, uploads go to object storage (the user’s test bucket, else minio on the database’s shared instance), never the app box’s disk, which a re-pin replaces with a new box.
  • Outside services (email, OAuth, payments, LLMs, object storage): use the user’s own keys. Copy them from their local env file (.env, .env.local) into a gitignored file you tesser env push to your boxes. Shared instances only get values through tesser env set, which is the human’s call: ask, or use stand-ins there. Test-mode keys only (Stripe sk_test_, a sandbox account): a live or production key becomes a stand-in too. Where there is no key: a local stand-in (mailpit, minio), a fake value where the app only checks one is set, or the app’s switch to turn the feature off. Never commit a key.
  • up.sh: path 2 by default. It checks every shared instance exists and prints the shared app’s URL; a missing one is an error naming the service, never something it pins. up.sh <svc>... is path 3: it devs those services from this worktree and prints their URLs. It never wires or restarts a shared instance.
Facts about boxes that are easy to get wrong:
  • tesser syncs only what git does not ignore. Run git status --ignored .agents .claude after writing files.
  • Node comes from the image’s fnm, at the repo’s pinned version. Use the block under Templates below; never install node or overwrite /usr/local/bin (pnpm and yarn there are corepack shims). If the repo installs its own node system-wide, remove those shims first, in the run script. A node version the image lacks installs under /usr/local/fnm, so setup reruns after a reboot until the image has it.
  • Host and origin. The box receives every request as Host: localhost:P, with Origin/Referer rewritten to http://localhost:P: base URLs, ALLOWED_HOSTS, CSRF trusted origins, auth callback URLs and any absolute URL the server hands the browser name localhost. The laptop sends a page’s calls to localhost:<port> to that page’s own box. A URL with no page behind it (an email link, a redirect back from an outside site) names the box instead: http://$TESSER_BOX_HOST:P.
  • A page that calls another port reaches its own box’s port, but cookies set on the page’s host do not follow. Proxy the API through the frontend dev server (Vite server.proxy, Next rewrites) when auth is a cookie.
  • Compile-time public env (NEXT_PUBLIC_*, VITE_*) must be in the run script’s environment when the dev server starts.
  • CSP and dev-origin checks must accept *.localhost.
  • tesser ssh runs on the box as it is, with no sync first; tesser exec and tesser sync copy the worktree.
  • A run script edit may not restart the dev server: after one, tesser sync <box> --restart.

2. Signed in

Find how the app makes a session (the login route, the cookie or token name, where the frontend keeps it: cookie, localStorage, both) by reading its auth code, then build:
  • A user and tenant per caller. login.sh [name] signs in as dev+<name>@example.com, creating that user idempotently with a constant password, its own tenant (workspace, org, team) named after it, an admin role, verified email and onboarding done. name defaults to the worktree’s folder. Use the app’s own seed, CLI, management command or signup API; SQL last. Two worktrees never share a user, so they never revoke each other’s sessions or see each other’s records.
  • Box-only switches that remove the human: email verification off or auto-confirmed, captcha off, the first-run wizard pre-completed. Gate each on an env var only boxes set. Every request reaches the box from one address, so while reading the auth code find its per-IP rate limits. Where the app takes the client IP from the leftmost X-Forwarded-For entry, login.sh and create.sh send an IP made from a hash of the caller’s name (10.<a>.<b>.<c>); the laptop daemon keeps it and appends its own hop. Check with curl that two names get separate limits. Otherwise, or if they don’t, skip the limit where $TESSER_SLOT is set, before you pin the app service (Order of work step 3): adding it later restarts the shared instance. Where the app only offers magic links or OAuth, enable its password or dev-login path, or catch the email in mailpit and script the click.
  • Every login.sh call makes an independent session (vary the device id or User-Agent if the app ties sessions together), with the app’s real login request, never a forged token. It prints JSON a browser can load ({"cookies":[{"name","value"}],"localStorage":{...}}) on stdout and writes a curl jar inside the worktree (gitignored), so two worktrees never share one. Prove independence: mint A, mint B, load B in a browser, then A must still be signed in. curl sends no Origin, so a cookie POST can be refused as cross-site (a 403 such as CSRF_ORIGIN_MISMATCH): send -H 'Origin: http://localhost:<port>', or use the app’s API token path. A jar is good only for the host it was minted on: a script that can target either the shared box or the worktree’s own box puts the host in the jar’s file name. The session you hand anyone comes from a login.sh call after your last check and nobody has opened: some apps rotate a refresh token on use and revoke the session when the old one comes back. Find out: load one session in two fresh browsers one after the other; if the second is signed out, say so in the repo skill.
Prove sign-in with curl against the auth endpoint first, printing status and body; the real error shows there in a second. Then load a signed-in page in a headless browser with only what login.sh printed. Browser scripts: never automate the login form; wait for domcontentloaded and one known selector, at most 20 s each, never networkidle against a dev server. The one exception: a dev server that compiles per route (Next, Vite) can take 60-90 s on a route’s first load: give that load 90 s once, never a retry loop, and put the measured first and warm load in the repo skill. On failure, or when the page lands on a login, setup or onboarding URL, print the final URL, the first 800 characters of text, and every response >= 400. A signed-in page is done when it loads with no page errors and no failed request to the app’s own hosts: fix each one, or name it in the repo skill with why it is harmless.

3. Data

  • A tenant worth testing in. The first login.sh <name> also fills that caller’s tenant with what a real customer has after onboarding: the app’s top-level object and the things its main pages show, made through the app’s API (a workspace with a project and some issues; an org with a deployment and a few pages; a team with a published survey and responses). A task agent then exercises real flows on its first call instead of building data. Later calls only sign in.
  • create.sh <name>: one more of the app’s main records in the caller’s tenant, printing the URL of the page where its name shows verbatim. Note name rules (characters, case, length; a slug is not a name).
  • db.sh: a database shell from the laptop (tesser ssh <infra> -- docker exec -i <db> psql …) and db.sh -c '<sql>' for one query. Say which tables hold the main records.
  • Those plus up.sh are all the next agent uses: prove them first.

4. The repo’s tesser skill

Template under The repo’s own skill, below. In this order: focused checks, Up (paths 2 and 3, with how to wait and how long each took you), Sign in, Data, Iterate, When it breaks. Copyable commands. Explain why in a sentence where the next agent would otherwise guess. Shared instances belong to whoever sets the repo up. The repo skill tells task agents to read from them and write only in their own tenant, and to report a missing or broken one (service, box, error) rather than rebuild it.

5. Prove it from zero

Commit, git worktree add a fresh checkout, and run exactly what the next agent would for path 2, in your own turn and in the foreground, reading only the repo skill: up.sh, login.sh, create.sh PROOF<6 random A-Z0-9>, then load the printed URL with the login.sh session in a headless browser and require the marker, case-sensitively, in the page’s text. Every place it is slow or wrong is a fix to a script or the skill: fix it and re-run only the step that failed. Start path 3 for the main app service from that worktree in the background before the proof and note its cold time if it lands before you finish; never wait on it, and write “not measured” in the repo skill if it has not. Last, tesser feedback "<what you ran, what happened, what you expected>" for the thing in tesser itself, not the repo, that cost you the most time.

The repo’s own skill: .agents/skills/tesser/SKILL.md

This is the only file the next agent reads. Cut any section the repo does not need; never pad one.

Templates

Generic versions of a working frontend + backend setup. Replace app, ports and commands; keep the shape. Paths assume .agents/skills/tesser/. Check field names against tesser service --help before committing.

Manifest: an app server

Manifest: infra containers

infra-dev.sh runs docker compose -f .agents/skills/tesser/compose.tesser.yml up in the foreground, then a provision step (buckets, queues) on every start, because in-memory emulators keep nothing across restarts. Pin image tags: a floating tag that stops pulling takes every new infra box down with it.

Setup: app-up.sh

Run: app-run.sh

Scheduled refresh of the shared backend

Infra is not in the loop: a new infra shared instance is a new, empty database. Re-pin infra only by hand, and right after it rerun the migrate step (section 1, One service migrates). Match the repo’s other workflows: if they pin action versions or tool checksums, pin tesser the same way (https://tesser.sh/docs/install lists each release’s sha256).

up.sh