# Build a custom template

A template is the image a sandbox boots from. You define one in TypeScript,
build it once, then create sandboxes from it by name.

<Callout kind="note">
The template builder ships only in the TypeScript SDK; the Python SDK covers
sandboxes, not templates. Build once with the TypeScript builder, then pass
the template name to `Sandbox.create` from either language.
</Callout>

## Install

The builder is the `@impello/sdk/template` subpath of the same package. It is
kept out of the root import so code that only creates sandboxes never loads a
Dockerfile parser.

```bash
npm install @impello/sdk
export IMPELLO_API_KEY=imp_...
```

Every builder call reads the key from `IMPELLO_API_KEY`. Keys are made at
[dashboard.impello.ai/keys](https://dashboard.impello.ai/keys); see
[API keys](/docs/api-keys).

## Define a template

`Template()` returns a builder. The first call says where the image comes
from, the rest add layers, and `Template.build` sends it off.

```ts
import { Template } from '@impello/sdk/template'

const template = Template()
  .fromTemplate('base')
  .aptInstall(['ffmpeg'])
  .pipInstall(['numpy', 'pandas'])
  .setUser('user')
  .setWorkdir('/home/user')

await Template.build(template, 'my-app:v1')
```

There are three starting points.

- `fromTemplate('base')` builds on top of a
  [default template](/docs/templates). `base` is what `Sandbox.create()` boots
  with no name.
- `fromImage('python:3.12-bookworm')` starts from any Docker image.
  `fromPythonImage('3')`, `fromNodeImage('lts')`, `fromBunImage('latest')`,
  `fromDebianImage('stable')`, `fromUbuntuImage('latest')`,
  `fromFedoraImage('44')`, `fromArchImage('latest')` and
  `fromAlpineImage('3.24')` are shortcuts, with those defaults.
- `fromDockerfile('Dockerfile')` reads an existing Dockerfile, given as a path
  or as its contents.

### Layers

<table>
  <thead>
    <tr><th>Method</th><th>What it does</th></tr>
  </thead>
  <tbody>
    <tr><td>`copy(src, dest)`</td><td>Copies a file or directory from the context into the image</td></tr>
    <tr><td>`runCmd(cmd)`</td><td>Runs a shell command; an array runs them joined with `&&`</td></tr>
    <tr><td>`aptInstall(pkgs)`</td><td>`apt-get update` then `apt-get install -y`, as root</td></tr>
    <tr><td>`pipInstall(pkgs)`</td><td>`pip install`, as root unless you pass `{ g: false }`</td></tr>
    <tr><td>`npmInstall(pkgs)`</td><td>`npm install`; `{ g: true }` installs globally as root</td></tr>
    <tr><td>`gitClone(url, path)`</td><td>Clones a repository; takes `branch` and `depth`</td></tr>
    <tr><td>`setEnvs(vars)`</td><td>Sets environment variables for the build steps that follow (build-time only)</td></tr>
    <tr><td>`setUser(user)`</td><td>Switches the user for the layers that follow</td></tr>
    <tr><td>`setWorkdir(dir)`</td><td>Sets the working directory</td></tr>
  </tbody>
</table>

`setEnvs` does not persist into sandboxes made from the template. For
variables that must exist in a running sandbox, pass `envs` to
`Sandbox.create`; see [Sandbox lifecycle](/docs/sandbox).

`runCmd`, `copy` and `gitClone` take a `user` option to run one layer as
somebody else. The default templates end as `user` in `/home/user`; end yours
the same way, so a command you run later lands in a home directory it can
write to.

<Callout kind="warning">
On a template built `fromImage`, the account `user` does not exist until the
first `setUser('user')` step. A layer that names it before that point —
`chown user:user`, `runCmd(..., { user: 'user' })`, `copy(..., { user: 'user' })`
— fails with `invalid user`. Switch user first, or create the account with
`runCmd('id -u user >/dev/null 2>&1 || useradd --create-home --shell /bin/bash user')`.
</Callout>

### Files and the context directory

`copy` paths are relative to the context directory, which defaults to the
directory of the file that called `Template()`. Set it with `fileContextPath`,
exclude files with `fileIgnorePatterns`, and a `.dockerignore` in the context
directory is read as well. A relative `fileContextPath` is resolved from the
working directory the build script runs in, not from the file, so pass an
absolute path.

```ts
import { join } from 'node:path'
import { Template } from '@impello/sdk/template'

const template = Template({
  fileContextPath: join(import.meta.dirname, 'ctx'),
  fileIgnorePatterns: ['node_modules/**'],
})
  .fromNodeImage('lts')
  .copy('package.json', '/app/')
  .copy('src', '/app/src')
  .setWorkdir('/app')
  .npmInstall()
```

A `copy` source must stay inside the context directory. An absolute path, or
one that climbs out with `..`, throws when you define the template, before
anything is uploaded.

## Start and ready commands

The start command runs once at the end of the build and the running state is
snapshotted, so every sandbox made from the template starts with the process
already up. The ready command decides when the build counts it as up.

```ts
import { Template, waitForPort } from '@impello/sdk/template'

const template = Template()
  .fromNodeImage('lts')
  .copy('server.js', '/app/server.js')
  .setWorkdir('/app')
  .setStartCmd('node server.js', waitForPort(3000))
```

Both Node examples stay `root` in `/app`, because the Node image has no `user`
account and `/app` is root-owned. To run later commands as `user`, add the
`useradd` line from the warning above and end the chain with
`.setUser('user').setWorkdir('/home/user')`.

`setStartCmd(start, ready)` takes the ready check as a helper or as a shell
command that exits 0 when ready. `setReadyCmd(ready)` sets the check without a
start command. Both finish the definition; nothing chains after them.

<table>
  <thead>
    <tr><th>Helper</th><th>Ready when</th></tr>
  </thead>
  <tbody>
    <tr><td>`waitForPort(port)`</td><td>Something is listening on `port`</td></tr>
    <tr><td>`waitForURL(url, statusCode)`</td><td>`url` answers with `statusCode`, 200 by default</td></tr>
    <tr><td>`waitForProcess(name)`</td><td>A process called `name` is running</td></tr>
    <tr><td>`waitForFile(path)`</td><td>`path` exists</td></tr>
    <tr><td>`waitForTimeout(ms)`</td><td>`ms` has passed, rounded down to whole seconds; anything under 1000 waits 1 second</td></tr>
  </tbody>
</table>

## Build

`Template.build(template, name, options)` uploads the context files, starts the
build and waits for it to finish. `name` is `name` or `name:tag`, up to 128
characters.

```ts
import { Template } from '@impello/sdk/template'

const template = Template().fromTemplate('base').aptInstall(['ffmpeg'])

const info = await Template.build(template, 'my-app:v1', {
  cpuCount: 2,
  memoryMB: 2048,
  onBuildLogs: (entry) => console.log(entry.toString()),
})

console.log(info.templateId, info.buildId, info.tags)
```

<table>
  <thead>
    <tr><th>Option</th><th>Type</th><th>Default</th></tr>
  </thead>
  <tbody>
    <tr><td>`cpuCount`</td><td>`number`</td><td>`2`</td></tr>
    <tr><td>`memoryMB`</td><td>`number`</td><td>`1024`</td></tr>
    <tr><td>`tags`</td><td>`string[]`</td><td>none</td></tr>
    <tr><td>`skipCache`</td><td>`boolean`</td><td>`false`</td></tr>
    <tr><td>`onBuildLogs`</td><td>`(entry) => void`</td><td>none</td></tr>
  </tbody>
</table>

`cpuCount` and `memoryMB` are the size of every sandbox created from the
template, not just of the build. The minimum is 1 vCPU and 128 MB. Each log
entry carries a `timestamp`, a `level` of `debug`, `info`, `warn` or `error`,
and a `message`; `toString()` puts the three on one line.

Layers are cached between builds, and a `copy` layer is rebuilt when the files
under it change. `skipCache: true` rebuilds everything. `.skipCache()` on the
builder skips the cache for every instruction after it.

A failed build throws `BuildError` carrying the message the build failed with;
see [Errors](/docs/errors).

### Limits

<table>
  <thead>
    <tr><th>Plan</th><th>Largest template</th><th>Builds in parallel</th></tr>
  </thead>
  <tbody>
    <tr><td>Micro</td><td>2 vCPU, 4 GB</td><td>1</td></tr>
    <tr><td>Base</td><td>4 vCPU, 8 GB</td><td>20</td></tr>
    <tr><td>Scale</td><td>4 vCPU, 8 GB</td><td>40</td></tr>
  </tbody>
</table>

A template cannot be built larger than the sandbox your plan allows. The
parallel-build limit is its own ceiling, counted apart from the number of
sandboxes you may run at once. Build time is not metered; only sandbox runtime
draws down credit. See
[Limits and pricing](/docs/limits-and-pricing).

## Build in the background

`Template.buildInBackground` returns as soon as the build is requested. Poll
`Template.getBuildStatus` with the ids it gives you.

```ts
import { Template } from '@impello/sdk/template'

const template = Template().fromTemplate('base').aptInstall(['ffmpeg'])
const info = await Template.buildInBackground(template, 'my-app:v1')

let logsOffset = 0
for (;;) {
  const status = await Template.getBuildStatus(info, { logsOffset })
  for (const entry of status.logEntries) console.log(entry.toString())
  logsOffset += status.logEntries.length

  if (status.status === 'ready' || status.status === 'error') {
    for (;;) {
      const tail = await Template.getBuildStatus(info, { logsOffset })
      if (tail.logEntries.length === 0) break
      for (const entry of tail.logEntries) console.log(entry.toString())
      logsOffset += tail.logEntries.length
    }
    if (status.status === 'error') throw new Error(status.reason?.message)
    break
  }

  await new Promise((resolve) => setTimeout(resolve, 2000))
}
```

`status` is one of `building`, `waiting`, `ready` or `error`. A call returns at
most 100 log entries, so a terminal status does not mean you have them all.
Keep polling at the advanced offset until a call comes back empty, as the loop
above does. Drop that drain and a build that logs more than 100 lines after
your last poll hides its tail, including the failing step's output on `error`.
`logsOffset` skips the ones you already have. On `error`, `reason` holds the
`message`, the failing `step` and its `logEntries`.

## Tags

A tag points a name at one build. Put the tag in the name, or list several in
`tags`, then move them between builds without rebuilding.

```ts
import { Template } from '@impello/sdk/template'

const template = Template().fromTemplate('base').aptInstall(['ffmpeg'])

await Template.build(template, 'my-app', { tags: ['v2', 'staging'] })

await Template.assignTags('my-app:v2', 'production')
await Template.removeTags('my-app', 'staging')

for (const tag of await Template.getTags('my-app')) {
  console.log(tag.tag, tag.buildId, tag.createdAt)
}
```

`assignTags(targetName, tags)` takes the source build as `name:tag`.
`assignTags` and `removeTags` each accept one tag or an array.
`getTags(templateId)` takes a name or an id and returns every tag.

## Private registries

`fromImage` takes registry credentials; `fromAWSRegistry` and `fromGCPRegistry`
take the provider's own.

```ts
import { Template } from '@impello/sdk/template'

const fromRegistry = Template().fromImage(
  'registry.example.com/team/app:1.4',
  {
    username: process.env.REGISTRY_USER!,
    password: process.env.REGISTRY_PASSWORD!,
  }
)

const fromEcr = Template().fromAWSRegistry(
  '123456789012.dkr.ecr.eu-central-1.amazonaws.com/app:1.4',
  {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    region: 'eu-central-1',
  }
)

const fromGar = Template().fromGCPRegistry(
  'europe-west3-docker.pkg.dev/project/repo/app:1.4',
  { serviceAccountJSON: 'service-account.json' }
)
```

`serviceAccountJSON` is a path relative to the context directory, or the
parsed JSON as an object. Credentials travel with the build request; keep them
out of the files you `copy` into the image.

## List, check and delete

`Template.exists(name)` answers `true` for a name that is taken, including a
default template and a name another team owns.

```ts
import { Template } from '@impello/sdk/template'

const taken = await Template.exists('my-app')
console.log(taken)
```

Listing and deleting have no SDK method. Do them on the dashboard at
[dashboard.impello.ai/templates/list](https://dashboard.impello.ai/templates/list),
or over HTTP.

```bash
curl "https://api.sandbox.impello.ai/v2/templates" \
  -H "X-API-Key: $IMPELLO_API_KEY"

curl -X DELETE "https://api.sandbox.impello.ai/templates/$TEMPLATE_ID" \
  -H "X-API-Key: $IMPELLO_API_KEY"
```

`GET /v2/templates` is paged. It takes `limit` and `nextToken` query
parameters and returns an `X-Next-Token` header when more results exist; pass
that token back as `nextToken` until the header is absent.

`TEMPLATE_ID` is the `templateId` that `Template.build` returned. Both routes
take the same `X-API-Key` header as the rest of the API.

## Create a sandbox from it

Pass the name to `Sandbox.create`. Everything else about the sandbox is the
same; see [Sandbox lifecycle](/docs/sandbox).

<CodeTabs>
<Tab label="TypeScript">

```ts
import { Sandbox } from '@impello/sdk'

const sandbox = await Sandbox.create('my-app')
const result = await sandbox.commands.run('ffmpeg -version')
console.log(result.stdout)
await sandbox.kill()
```

</Tab>
<Tab label="Python">

```python
from impello import Sandbox

sandbox = Sandbox.create(template="my-app")
result = sandbox.commands.run("ffmpeg -version")
print(result.stdout)
sandbox.kill()
```

</Tab>
</CodeTabs>

<Callout kind="note">
`Template.toDockerfile(template)` prints the equivalent Dockerfile for a
template that starts from an image. It throws for one that starts
`fromTemplate`, because those layers are not a Dockerfile.
</Callout>

Next: [Sandbox lifecycle](/docs/sandbox).
