Deploy targets

tai deploy puts an instance somewhere. TAI ships one target, docker, which runs a container on the local machine. Everything else (AWS, GCP, Fly, Hetzner, your company's internal platform) is a plugin.

bash
tai deploy list
tai deploy plan docker         # describe what `up` would do, change nothing
tai deploy up docker
tai deploy status docker
tai deploy down docker
tai deploy help docker

Arguments after -- go to the target, so its flags can collide freely with TAI's:

bash
tai deploy up docker -- --force-recreate

Available targets

TargetPackage
dockerbuilt inContainer on this machine.
aws-ec2@tailored-ai/deploy-awsSingle EC2 instance, encrypted EBS volume.

Where the pieces live

PiecePackageWhy
DeployTarget and friends@tailored-ai/core, types onlyThe package every plugin already depends on. The import erases at compile time, so the runtime cost is nil.
Registry, discovery, the command@tailored-ai/cliDeployment is a CLI-time concern. Nothing in the agent runtime needs to know how it was deployed.
docker target@tailored-ai/cliA reference implementation, so the seam is exercised rather than assumed.

Writing one

A deploy plugin exports a deployTargets array. That is the whole contract.

ts
import type { DeployTarget } from "@tailored-ai/core";

const flyTarget: DeployTarget = {
  id: "fly",
  description: "Deploy TAI to a Fly.io machine with a persistent volume.",
  help: "tai deploy up fly\n\nNeeds flyctl on PATH and `fly auth login`.",

  async plan(ctx) {
    const problems: string[] = [];
    if (!hasFlyctl()) problems.push("`flyctl` is not on PATH.");
    return {
      problems,
      steps: [
        { title: "create the app and a 10GB volume", consequential: true },
        { title: "set secrets from the local .env" },
        { title: "deploy the image" },
      ],
    };
  },

  async up(ctx) {
    ctx.log("Creating volume…");
    // …
    return { ok: true, summary: "deployed", url: "https://my-tai.fly.dev" };
  },

  async down(ctx) { /* … */ },
  async status(ctx) { /* … */ },
};

export const deployTargets: DeployTarget[] = [flyTarget];

Install it and the target appears:

bash
tai plugin install @acme/tai-deploy-fly
tai deploy list

Rules that matter

plan must not change anything. It is what someone runs to decide whether to trust your target. Put every precondition you check into problems: missing binary, absent credential, bound port. tai deploy up calls plan first and refuses when problems is non-empty, so a good plan turns a half-finished deployment into a clean refusal.

Mark consequential steps. Anything that costs money, touches a remote account, or is hard to undo. The CLI flags those with ! so a plan stays skimmable for the parts that matter.

Return a url from up when there is one. It is the first thing anyone wants.

Be conservative in down. The built-in docker target stops the container and keeps the volume, telling you the one command that would delete it. Destroying an instance's database, config, and installed plugins should not be something a person infers from the word "down". If your target cannot preserve state (aws-ec2 cannot, since the volume is DeleteOnTermination), say so in the summary.

Use ctx.log, not console.log, so output matches the rest of the command.

Discovery happens by installation

Every other extension point registers through PluginContext during loadPlugins, which needs a loaded config.yaml. Deploy targets cannot work that way, because tai deploy is frequently the command that creates the instance a config would describe.

So the CLI enumerates packages installed under <TAI_HOME>/plugins/, imports each, and reads the deployTargets named export. That is the same shape the plugin loader already uses for the optional meta and validateConfig exports. Nothing needs to appear in plugins: for a deploy target to work.

Three consequences:

  • Your module gets imported by tai deploy, so keep top-level side effects out of it. Do the work inside plan and up.
  • A plugin that fails to import is reported by tai deploy list and skipped. One broken package does not make the command unusable.
  • Registering an existing id overrides it. Replacing the built-in docker target is legitimate, and the override is reported, since "my target stopped working" is otherwise an unsearchable symptom.

What a cloud target should target

A single VM with a persistent disk. TAI's state is SQLite and takes one writer, so there is no replica set to manage. EC2 or Lightsail, Compute Engine, a Droplet, a Hetzner box.

Serverless container platforms (Fargate, Cloud Run, App Runner) fit badly, and a target for one should say so rather than paper over it. They assume a stateless replaceable container. SQLite on a network filesystem breaks WAL locking, and scale-to-zero stops cron and autopilot.

See Self-hosting for the deployment shape itself and the auth options a target should wire up.