# Templates and secrets

Make sandboxes from a template, and give every one of them the same variables and files.

A template is what a sandbox is made as and held to, under one name: its start, which is a size and a snapshot or a new machine, its variables and files, and its [outbound rule](https://agental.io/docs/outbound-rules). Every account has a template named `default`, which a sandbox is on when it is made without naming one and which cannot be deleted.

- A sandbox is on one template at a time.
- A sandbox can have variables and files of its own too, on top of its template's. Where both name the same variable or the same path, the sandbox's own is used.
- A sandbox can have addresses of its own too, which add to what its template's outbound rule lets out.
- There is nothing above the template: no secret applies to every sandbox of the account but through a template.

## Make a template

`POST /v1/templates`

```shell
curl -X POST https://agental.io/v1/templates \
  -H "Authorization: Bearer $AGENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"coding-agent"}'
```

It starts empty, at the size `default` starts sandboxes at, on a new machine. Its name is used once in the account. `GET /v1/templates` lists them, `default` first, with the names of their variables and files and how many sandboxes are on each.

## Give it secrets

`PUT /v1/templates/{id}/secrets/variables/{name}`

```shell
curl -X PUT https://agental.io/v1/templates/{id}/secrets/variables/DATABASE_URL \
  -H "Authorization: Bearer $AGENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value":"postgres://user:pass@db.example.com/app"}'
```

`PUT /v1/templates/{id}/secrets/files`

```shell
curl -X PUT https://agental.io/v1/templates/{id}/secrets/files \
  -H "Authorization: Bearer $AGENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"path":".npmrc","text":"//registry.npmjs.org/:_authToken=…\n"}'
```

- A variable is in the environment of every command. Its name is letters, digits and underscores, and does not start with a digit.
- A file is written into the sandbox before every command, at a path relative to `/opt/agental`. A file can be up to 10 MB.
- Each call sets one. An empty value is kept as an empty value: `DELETE` removes one.
- `GET …/secrets/variables` and `GET …/secrets/files` give names and paths only. A value is read one at a time: `GET …/secrets/variables/{name}`, and `GET …/secrets/files?path=` for a file.
- A file's path goes in the query string, as `path`, because it may hold slashes. It is kept written one way: `./.config//gh/hosts.yml` is `.config/gh/hosts.yml`.

> **Warning:** Whoever can run a command in a sandbox can read these values, unless a variable is [kept out of the sandbox](https://agental.io/docs/templates#keep-a-variable-out-of-the-sandbox). Give a template only what its sandboxes need.

## Make a sandbox from it

`POST /v1/sandboxes`

```shell
curl -X POST https://agental.io/v1/sandboxes \
  -H "Authorization: Bearer $AGENTAL_API_KEY" \
  -H "Idempotency-Key: fix-login-bug-1" \
  -H "Content-Type: application/json" \
  -d '{"name":"fix-login-bug","templateId":"tpl_0123456789abcdef"}'
```

- Leave `templateId` out and the sandbox is on `default`.
- The template's start says its size, what it starts from and how long it runs after each start. `size`, `snapshotId` and `ttlSeconds` in the request win over it. `snapshotId: null` asks for a new machine whatever the start says, and a snapshot named in the request with no size gives the snapshot's size.
- Its commands have the template's variables and files from the first.
- It is held to the template's outbound rule from its first boot, with any addresses of its own on top.
- A fork starts on the same template as its source, with the same secrets and addresses of its own.

## Set its start

`PUT /v1/templates/{id}/start`

```shell
curl -X PUT https://agental.io/v1/templates/{id}/start \
  -H "Authorization: Bearer $AGENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"size":"small","snapshotId":null,"ttlSeconds":14400}'
```

`ttlSeconds` is how long its sandboxes run after each start: null is the deployment's default, and left out it stays as it was. `snapshotId` left out is a new machine. It is for sandboxes made from now on. A sandbox already on the template keeps its size, its disk and its time limit. A snapshot needs a size with at least the disk it was taken on. A new size has to be one your plan makes: `402 size_not_on_plan` if it is not. The size it starts as already is kept, so its snapshot alone can be changed.

## Set what its sandboxes may reach

`PUT /v1/templates/{id}/network`

```shell
curl -X PUT https://agental.io/v1/templates/{id}/network \
  -H "Authorization: Bearer $AGENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"outbound":"blocked"}'
```

`open`, `blocked`, or `allowlist` with the addresses in `allow`. Every template is open until this is set, `default` included. A change raises the template's `revision`, as a change to its secrets does. [Outbound rules](https://agental.io/docs/outbound-rules) says what a sandbox on it is then held to.

## When a change reaches a sandbox

A sandbox has its template's variables and files, and its outbound rule, as they were when it last started. A change to a template does not reach a sandbox that is running: it takes it when it next starts, or now when you apply it.

`POST /v1/templates/{id}/apply`

```shell
curl -X POST https://agental.io/v1/templates/{id}/apply \
  -H "Authorization: Bearer $AGENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sandboxIds":["sbx_2a9a47e50daec64a"]}'
```

- Leave `sandboxIds` out, or send `{}`, to bring every running sandbox on the template up to date.
- Nothing is restarted. The next command gets the variables as they are now, and the files are written to the disk at once, and in any case before the next command.
- Each is held to the template's outbound rule from when the request answers, its server told first. One whose server does not take the rule is listed in `failed` and left as it was. `applied` lists the rest.
- It is the only call that changes a running sandbox because of its template. A stopped sandbox named is refused with `409 sandbox_not_running`: it takes the change when it starts.
- `GET /v1/templates/{id}/sandboxes` lists the sandboxes on a template with `upToDate` for each, and `GET /v1/sandboxes/{id}/template` says it for one.

## A sandbox's own secrets

`PUT /v1/sandboxes/{id}/secrets/variables/{name}`

```shell
curl -X PUT https://agental.io/v1/sandboxes/{id}/secrets/variables/API_TOKEN \
  -H "Authorization: Bearer $AGENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value":"a token for this sandbox only"}'
```

- The same calls as a template's, under `/v1/sandboxes/{id}/secrets`: `variables`, `variables/{name}` and `files`.
- One is used from the sandbox's next command, without a restart and without `apply`.
- Remove one and the sandbox has its template's again, where the template has one of that name.

## Keep a variable out of the sandbox

Set a variable with `hosts` and it is kept out of the sandbox. Its commands see a stand-in in place of the value, and the server the sandbox runs on puts the value in only on HTTPS requests to those hosts. The value is never on the sandbox's disk, so nothing that runs there can read it, root included.

`PUT /v1/templates/{id}/secrets/variables/{name}`

```shell
curl -X PUT https://agental.io/v1/templates/{id}/secrets/variables/GITHUB_TOKEN \
  -H "Authorization: Bearer $AGENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value":"ghp_…","hosts":["github.com","api.github.com"]}'
```

```json
{
  "name": "GITHUB_TOKEN",
  "hidden": true,
  "hosts": ["github.com", "api.github.com"],
  "standIn": "agental_hidden_7hq2xk9mw4rz3st8vn1bq5c0dle6fy2p"
}
```

- The answer has the hosts and the stand-in, and not the value. From now on nothing gives the value back, not the API and not the console, to you or to anyone. To change it, set it again.
- Set again with a new value, it keeps its stand-in and takes the hosts sent this time. Set again without `hosts`, it is plain from then on, and its commands read its value.
- `GET …/secrets/variables/{name}` answers with `hidden: true`, its hosts and its stand-in. `GET …/secrets/variables` has every name in `names` and the ones kept out in `hidden`. `GET /v1/templates/{id}` has the same list as `hidden`, and so does `GET /v1/sandboxes/{id}/template` for the variables a sandbox has from its template.
- A sandbox's own variable is kept out the same way, under `/v1/sandboxes/{id}/secrets`. Where a sandbox has its own variable of a name its template has too, its own is used, kept out or not.
- It reaches sandboxes as any other secret does: a template's at a sandbox's next start or when you apply it, and a sandbox's own from its next command. A fork has its source's own secrets, with the same stand-ins.
- It is free on every plan. Where `GET /v1/policy` says `hiddenVariables: false`, a variable sent with `hosts` is refused with `501 hidden_variables_unavailable`, and one already kept out stays out.

## Narrow its hosts

`PUT /v1/templates/{id}/secrets/variables/{name}/hosts`

```shell
curl -X PUT https://agental.io/v1/templates/{id}/secrets/variables/GITHUB_TOKEN/hosts \
  -H "Authorization: Bearer $AGENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"hosts":["api.github.com"]}'
```

- The hosts that are sent take the place of the ones it had. Its value and its stand-in stay as they are, so the value is not sent again.
- Each host has to be one it is sent to now, or a name under one of its wildcards. A new host is refused with `409 hosts_widened`: only someone who has the value may send it somewhere new, so set it again with its value and `hosts`. A key that may change secrets cannot send a value it was never given to a host of its choosing.
- A plain variable is refused with `409 not_hidden`. To keep one out, set it with its value and `hosts`.
- The change reaches sandboxes as a new value would.

## What its commands see

```shell
$ echo "$GITHUB_TOKEN"
agental_hidden_7hq2xk9mw4rz3st8vn1bq5c0dle6fy2p
```

- The stand-in: `agental_hidden_` and 32 lowercase letters and digits. It is the same in every sandbox on the template, and stays the same when the value is replaced.
- Use it where the key would go: in a header, in a URL, or in a file such as `.npmrc` or `~/.git-credentials`. On an HTTPS request to one of the variable's hosts, the server puts the value in place of the stand-in in the request's headers and its URL, inside `Authorization: Basic` too, so `git push` over HTTPS works.
- In the answer, it puts the stand-in back in place of the value, in the headers and the body, so the value does not come into the sandbox that way either.
- A file that holds the stand-in keeps working when the value is replaced, and an agent that has been running for hours sends the new value with its next request, without a restart.
- The value is never on the sandbox's disk, so a snapshot or a fork holds only the stand-in, and so does an archived sandbox.
- Sent anywhere else, the stand-in is worth nothing.

## Which hosts

- 1 to 10 for each variable: hostnames, as `api.github.com`, and wildcards, as `*.example.com`, which cover every name under `example.com` and not `example.com` itself.
- Lowercase, with no scheme, port, path or IP address. An international name is written in its `xn--` form.
- A name on the [Public Suffix List](https://publicsuffix.org/list/), its private section included, is refused: it is not one host but a place where anyone can have one. So is a wildcard directly over one, as `*.com`, `*.co.uk` and `*.github.io` are. Name the hosts themselves, or a wildcard under a domain someone owns. GitHub's downloads are such a case: `githubusercontent.com` is on the list, so `*.githubusercontent.com` is refused, and `raw.githubusercontent.com` and `objects.githubusercontent.com` are each named.
- A host that cannot be one is refused with `400 invalid_host`, and `message` says which and why.
- Hosts do not open the [outbound rule](https://agental.io/docs/outbound-rules): a template that blocks everything still blocks the hosts listed here, and one held to an allowlist reaches them only if their addresses are on it.

## Only over HTTPS, and only to its hosts

- The value is put in only on HTTPS to port 443. A request over plain HTTP carries the stand-in out as it is.
- The server opens only the connections to a variable's hosts, with a certificate authority made for that sandbox alone, which the sandbox trusts. Every other HTTPS connection passes through untouched and is never decrypted.
- It checks the real host's certificate against the public certificate authorities, and puts nothing in when that fails.
- A request whose `Host` header names another host than the one its connection was opened for gets nothing put in.

## Tools that trust the sandbox's authority

The image puts each sandbox's authority in the system's store of certificates, and names it in `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE` and `DENO_CERT`. Node.js, Python, curl, git, Go and Rust tools trust it with nothing to set, and Java does through the system's keystore, which `ca-certificates-java` keeps.

A tool that ships its own fixed list of authorities and reads none of these fails TLS to the variable's hosts. Tell it to use the system's certificates if it can, or keep that variable plain.

## What a kept-out variable does not do

- It does not change a request's body. A stand-in in JSON or a form is sent as it is: put the key in a header or the URL.
- It does not change WebSocket frames after the upgrade. The upgrade request's headers are changed as any request's are.
- It does not carry HTTP/2 or HTTP/3 to a variable's hosts. The server speaks HTTP/1.1 to them, so a client that would use HTTP/2 uses HTTP/1.1, and in a sandbox that holds a kept-out variable HTTP/3 falls back to TCP.
- It does not get past a tool that checks a key's shape, by its length or its first characters, before it sends it. Such a tool may refuse the stand-in: keep that variable plain.

## Put a sandbox on another template

`PUT /v1/sandboxes/{id}/template`

```shell
curl -X PUT https://agental.io/v1/sandboxes/{id}/template \
  -H "Authorization: Bearer $AGENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"templateId":"tpl_0123456789abcdef"}'
```

It has that template's variables and files from its next command, running or not, and is up to date with it. It is held to that template's outbound rule at once if it is running, and from its next start if it is stopped. Its size, its disk, and its own secrets and addresses stay as they are.

## Delete a template

`DELETE /v1/templates/{id}`

A template that sandboxes are on is refused with `409 template_in_use`, and no sandbox is moved off it: put them on another template first. `default` is never deleted.

## What it does not do

- A file removed from a template, or from a sandbox's own, is no longer written. A copy already written to a sandbox's disk stays there until it is deleted there.

## Errors

| Answer | Meaning |
| --- | --- |
| `404 not_found` | No template of yours has that id, or the sandbox and the template are of different accounts. |
| `409 name_taken` | The account has a template of that name, or the name is `default`. |
| `409 default_template` | `default` is not deleted or renamed. |
| `409 template_in_use` | Sandboxes are on the template. `message` says how many. |
| `409 not_on_template` | `apply` named a sandbox that is not on the template. Nothing was applied. |
| `409 sandbox_not_running` | `apply` named a sandbox that is not running. Nothing was applied. |
| `409 too_many_addresses` | The template's outbound rule and a sandbox's own addresses would come to more than 100. Nothing was changed. |
| `409 not_hidden` | `PUT …/hosts` named a plain variable, whose commands read its value. To keep it out, set it with its value and `hosts`. Nothing was changed. |
| `409 hosts_widened` | `PUT …/hosts` named a host the variable is not sent to now. To send it somewhere new, set it again with its value and `hosts`. Nothing was changed. |
| `400 size_too_small` | The start's snapshot was taken on a size with more disk. |
| `400 invalid_host` | A host cannot be one, or there are none or more than 10. `message` says which and why. Nothing was changed. |
| `501 hidden_variables_unavailable` | A variable cannot be kept out of its sandbox here, as `GET /v1/policy` says with `hiddenVariables`. Set it without `hosts` for its commands to read it. Nothing was changed. |

---

Every page of these docs as Markdown: [llms.txt](https://agental.io/llms.txt)
