tnl.dev :: docs

project configuration.

configure a selected team and named services with TypeScript, YAML, or JSON.

Project configuration records the selected team and named local services beside the application. Use TypeScript for worktree-aware values, or use YAML or JSON for a static document. tnl init creates the initial file; the formats below are equivalent ways to maintain it.

install the configuration package

The client, configuration types, and framework integrations ship together:

install one package
npm install --save-dev @tnldotdev/tnl@next

Import framework integrations from @tnldotdev/tnl/vite and @tnldotdev/tnl/next.

configure with typescript

TypeScript configuration is implicitly format version 1 and uses camel-case field names:

tnl.config.ts
export default {
  team: "Acme Engineering",
  services: {
    web: {
      directory: "apps/web",
      publish: { target: 3000 },
      dev: {
        command: ["npm", "run", "dev"],
        startupTimeout: "90s",
      },
    },
    api: {
      directory: "apps/api",
      tunnel: { public: true },
      publish: { target: 8787 },
      dev: { command: ["npm", "run", "dev:api"] },
    },
  },
};

The team value may be a team ID or an unambiguous display name. Service names are stable local identifiers used in hostname derivation, reconciliation, status, and diagnostics. A service name is one lowercase DNS label, begins with a letter, and is at most 32 characters.

server, team, tunnel, publish, and dev may also appear at the root of the tnl value as project defaults. A named service inherits those defaults and overrides only the fields it supplies. This keeps shared team and visitor policy in one place while each service owns its target and command.

Use a factory only when a value truly needs project context:

tnl.config.ts
export default ({ env, worktree }) => ({
  team: env.TEAM_NAME ?? "Acme Engineering",
  services: {
    web: {
      tunnel: { subdomain: `web-${worktree.label}` },
      publish: { target: 3000 },
      dev: { command: ["npm", "run", "dev"] },
    },
  },
});

The factory receives a deeply frozen context:

TypeScript context: Values supplied to a tnl.config.ts configuration factory.
valuemeaning
cwddirectory where tnl was invoked
envenvironment without TNL_* or TNLD_*
worktree.rootGit worktree root, or cwd outside Git
worktree.namebase name of the root
worktree.labelDNS-safe name stable for the worktree and client state
worktree.isGitwhether a Git worktree was found

Node.js 22.18 or newer is required to evaluate tnl.config.ts. The sanitized context prevents config code from reading tnl client or server credentials through env; a project config is still trusted local code with normal Node.js access.

configure with yaml

Static configuration always requires both the root version: 1 field and the root tnl: envelope. It uses snake-case field names:

tnl.yml
$schema: https://tnl.dev/schema/v1.json
version: 1
tnl:
  team: Acme Engineering
  services:
    web:
      directory: apps/web
      publish:
        target: 3000
      dev:
        command: [npm, run, dev]
        startup_timeout: 90s
    api:
      directory: apps/api
      tunnel:
        public: true
      publish:
        target: 8787
      dev:
        command: [npm, run, dev:api]

Do not omit version, move it under tnl, or move service fields beside the tnl envelope. Those are different document shapes and strict validation rejects them.

configure with json

JSON has the same versioned root document and snake-case names:

tnl.json
{
  "$schema": "https://tnl.dev/schema/v1.json",
  "version": 1,
  "tnl": {
    "team": "Acme Engineering",
    "services": {
      "web": {
        "directory": "apps/web",
        "publish": { "target": 3000 },
        "dev": {
          "command": ["npm", "run", "dev"],
          "startup_timeout": "90s"
        }
      },
      "api": {
        "directory": "apps/api",
        "tunnel": { "allow_ip": ["198.51.100.0/24"] },
        "publish": { "target": "http://127.0.0.1:8787" },
        "dev": { "command": ["npm", "run", "dev:api"] }
      }
    }
  }
}

Do not place access tokens or other secrets in project configuration. Automatic browser authentication and saved client state supply credentials independently.

understand service fields

Each named service has the same route, publish, and development concerns:

named service fields: Project configuration fields, their TypeScript and static names, and where they apply.
TypeScriptYAML / JSONmeaning
teamteamselected team ID or display name
servicesservicesmap of stable service names
services.<name>.directoryservices.<name>.directoryservice directory relative to the config
tunnel.hosttunnel.hostexact route hostname
tunnel.subdomaintunnel.subdomainmember-namespace child label
tunnel.allowIPtunnel.allow_ipadditional visitor addresses or prefixes
tunnel.publictunnel.publicallow every visitor address
tunnel.ephemeraltunnel.ephemeralremove the route when the tunnel stops
publish.targetpublish.targetport or loopback HTTP origin
dev.commanddev.commanddevelopment process and arguments
dev.portdev.portoptional fixed loopback port
dev.startupTimeoutdev.startup_timeoutmaximum local startup wait

host and subdomain are mutually exclusive. public and allowIP are mutually exclusive. Targets must be a port or loopback HTTP origin. Ports range from 1 through 65535, and timeouts use Go duration syntax such as 30s, 2m, or 1m30s.

When no host or subdomain is configured, tnl derives one from the Git worktree and service name. An explicit service hostname is appropriate for a long-lived shared callback; the derived default is safer for parallel feature worktrees.

discovery

From the current directory, tnl searches upward for the nearest tnl.yml, tnl.yaml, tnl.json, or tnl.config.ts. Discovery stops at the Git worktree root. Outside Git, only the current directory is searched.

Only one candidate can exist in a directory. Select a specific file when a directory intentionally contains more than one:

select a configuration file
tnl --config config/tnl.preview.yml dev

TNL_CONFIG provides the same explicit selection. Use --no-config to disable discovery for one command.

precedence

Configuration resolves one field at a time. Command options have highest precedence, followed by supported TNL_* environment values, the named service override, root project defaults, and built-in defaults. The worktree-derived hostname is the default only when no higher source supplies a host or subdomain.

The team resolves separately: a named service team overrides the root project team; the project value overrides the team saved by tnl team use; and a missing or stale saved selection falls back to the personal team.

An explicit access token combined with a project-provided server is rejected unless the server is also explicit on the command line or in TNL_SERVER. This prevents a credential from being sent to an unexpected config-selected origin.

inspect the schema and resolved config

Editors can load the strict JSON Schema from https://tnl.dev/schema/v1.json. The hosted copy is generated from the peer tnl repository's schema/v1.json; it is not maintained by hand. The schema defines the versioned static YAML/JSON shape, including the required root envelope, but TypeScript continues to use the package's camel-case types.

Show the selected file without evaluating it, then validate it:

inspect project configuration
tnl config path
tnl config check

Unknown fields, duplicate YAML keys, ambiguous config files, invalid service names, and mutually exclusive settings fail before authentication or route changes. Errors identify the selected file and invalid setting; target diagnostics link to contextual recovery help when startup reaches that boundary.

Continue with development workflow, Vite, or Next.js.