agental 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -536
- package/bin/agental.js +1 -1
- package/dist/cli.js +463 -121
- package/package.json +12 -25
- package/dist/client.d.ts +0 -321
- package/dist/client.js +0 -451
- package/dist/errors.d.ts +0 -62
- package/dist/errors.js +0 -65
- package/dist/http.d.ts +0 -69
- package/dist/http.js +0 -246
- package/dist/index.d.ts +0 -10
- package/dist/index.js +0 -5
- package/dist/openapi.d.ts +0 -17070
- package/dist/openapi.js +0 -5
- package/dist/sandbox.d.ts +0 -605
- package/dist/sandbox.js +0 -846
- package/dist/template.d.ts +0 -183
- package/dist/template.js +0 -261
- package/dist/types.d.ts +0 -198
- package/dist/types.js +0 -1
- package/dist/version.d.ts +0 -2
- package/dist/version.js +0 -2
- package/dist/webhooks.d.ts +0 -6
- package/dist/webhooks.js +0 -16
package/README.md
CHANGED
|
@@ -1,546 +1,15 @@
|
|
|
1
1
|
# agental
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The `agental` command for [agental.io](https://agental.io): a Linux virtual machine of your own for each agent, from the terminal.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
npm install agental
|
|
7
|
-
```
|
|
8
|
-
|
|
9
|
-
Node 20.16 or later. No dependencies. The package is ESM only: `import`, not `require`. It uses only `fetch` and Web Crypto, so Bun, Deno and Cloudflare Workers should run it too, but it is only tested on Node.
|
|
10
|
-
|
|
11
|
-
## The agental command
|
|
12
|
-
|
|
13
|
-
The same package is the `agental` command. To sign in on this computer and get a shell in a new sandbox:
|
|
5
|
+
To sign in on this computer and get a shell in a new sandbox:
|
|
14
6
|
|
|
15
7
|
```bash
|
|
16
8
|
npx agental@latest sh
|
|
17
9
|
```
|
|
18
10
|
|
|
19
|
-
`npx agental@latest` alone signs in through
|
|
20
|
-
|
|
21
|
-
## Start
|
|
22
|
-
|
|
23
|
-
Make an API key in the console and put it in `AGENTAL_API_KEY`. Without one, the SDK uses the sign-in the agental command keeps on this computer, in `~/.config/agental/credentials.json`: `npx agental@latest` makes it.
|
|
24
|
-
|
|
25
|
-
```ts
|
|
26
|
-
import { Agental } from "agental";
|
|
27
|
-
|
|
28
|
-
const agental = new Agental();
|
|
29
|
-
|
|
30
|
-
const sandbox = await agental.sandboxes.create({ size: "small" }); // tiny, small, medium or large
|
|
31
|
-
|
|
32
|
-
const { stdout, exitCode } = await sandbox.exec("uname -a");
|
|
33
|
-
console.log(exitCode, stdout);
|
|
34
|
-
|
|
35
|
-
await sandbox.files.write("app/main.py", "print('hello')\n");
|
|
36
|
-
console.log((await sandbox.exec(["python3", "/opt/agental/app/main.py"])).stdout);
|
|
37
|
-
|
|
38
|
-
await sandbox.delete();
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
`create` answers when the machine is running. A sandbox you already have is `agental.sandboxes.get(id)`, and `agental.sandboxes.list()` gives all of them.
|
|
42
|
-
|
|
43
|
-
## Commands
|
|
44
|
-
|
|
45
|
-
```ts
|
|
46
|
-
await sandbox.exec(["ls", "-la", "/opt/agental"]); // the arguments exactly, no shell
|
|
47
|
-
await sandbox.exec("cd /opt/agental && npm test"); // a line of shell, run by sh -c
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
- A command that fails is an answer, not an error: read `exitCode`.
|
|
51
|
-
- Every command starts fresh, as root in `/root`. A `cd` or an `export` does not carry over to the next one.
|
|
52
|
-
- A command gets 60 seconds unless you pass `timeoutMs`, up to `90000`. One that runs out is killed and exits `124`. For longer, use `commands.run` below, up to `600000`.
|
|
53
|
-
- Each of `stdout` and `stderr` is cut at 1 MB, and `truncated` is then true.
|
|
54
|
-
|
|
55
|
-
To see output as it arrives:
|
|
56
|
-
|
|
57
|
-
```ts
|
|
58
|
-
for await (const event of sandbox.execStream("npm test")) {
|
|
59
|
-
if (event.type === "exit") console.log("exited", event.exitCode);
|
|
60
|
-
else process.stdout.write(event.data);
|
|
61
|
-
}
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
For a command that takes more than a minute, do not hold a request open for it:
|
|
65
|
-
|
|
66
|
-
```ts
|
|
67
|
-
const build = await sandbox.commands.run("cd /opt/agental && make", { timeoutMs: 600_000 });
|
|
68
|
-
console.log(build.status, build.exitCode, build.stdout);
|
|
69
|
-
|
|
70
|
-
// or start it and look later
|
|
71
|
-
const started = await sandbox.commands.start("make");
|
|
72
|
-
const now = await sandbox.commands.get(started.id);
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
For work that must outlast ten minutes, start it in the background inside the machine: `sandbox.exec("nohup npm run dev > dev.log 2>&1 < /dev/null &")`.
|
|
76
|
-
|
|
77
|
-
## On a schedule
|
|
78
|
-
|
|
79
|
-
```ts
|
|
80
|
-
const tick = await sandbox.schedules.create({ name: "scrape", command: "python scrape.py", every: "1h" });
|
|
81
|
-
await sandbox.schedules.create({ command: "./digest.sh", cron: "0 7 * * 1-5", timeZone: "Europe/Sofia" });
|
|
82
|
-
|
|
83
|
-
await sandbox.schedules.run(tick.id); // one run now
|
|
84
|
-
const runs = await sandbox.schedules.runs(tick.id); // the latest first: outcome, exitCode, commandId
|
|
85
|
-
await sandbox.schedules.update(tick.id, { enabled: false }); // paused
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
- When a run finds the sandbox stopped, it starts it, runs, and stops it again when the command ends, keeping its disk: you pay for the minutes it runs. `keepMemory: true` keeps its memory too. `ifStopped: "skip"` runs only while it runs.
|
|
89
|
-
- A sandbox someone started again, sent a command or moved the stop of meanwhile is left running.
|
|
90
|
-
- Every 5 minutes at the most: for anything more often, leave the sandbox running and loop inside it. `at` runs it once, at a date and time, as for a check you mean to come back to.
|
|
91
|
-
- After 10 runs one after another fail, a schedule pauses itself and says why in `pausedReason`; resuming it clears that.
|
|
92
|
-
- While a run still goes, the next is skipped. `overlap: "replace"` stops it instead, `"parallel"` runs both.
|
|
93
|
-
- A run gets 10 minutes unless `timeoutMs` says, up to six hours. One that could not be made on time is `missed`, and never made up later.
|
|
94
|
-
|
|
95
|
-
## Files
|
|
96
|
-
|
|
97
|
-
Paths are relative to the work folder, `/opt/agental`. A file can be up to 10 MB.
|
|
98
|
-
|
|
99
|
-
```ts
|
|
100
|
-
await sandbox.files.write("notes.txt", "hi");
|
|
101
|
-
await sandbox.files.read("notes.txt"); // "hi"
|
|
102
|
-
await sandbox.files.write("logo.png", bytes); // a Uint8Array
|
|
103
|
-
await sandbox.files.readBytes("logo.png");
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
## Stop, start, fork, snapshot
|
|
107
|
-
|
|
108
|
-
```ts
|
|
109
|
-
await sandbox.stop(); // the disk is kept, what was running is not, and it costs 5% of the running price
|
|
110
|
-
await sandbox.stop({ keepMemory: true }); // or keep its memory too, at 10%: start() puts back every process as it was
|
|
111
|
-
await sandbox.start(); // waits until it is running, also when it has to be brought back from storage
|
|
112
|
-
|
|
113
|
-
const copy = await sandbox.fork(); // a new sandbox with the same disk
|
|
114
|
-
|
|
115
|
-
const snapshot = await sandbox.snapshot("before-upgrade");
|
|
116
|
-
await sandbox.restore(snapshot.id); // roll the disk back
|
|
117
|
-
const fresh = await agental.sandboxes.create({ snapshotId: snapshot.id });
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
## Regions
|
|
121
|
-
|
|
122
|
-
```ts
|
|
123
|
-
const regions = await agental.regions(); // the default first, each with the sizes it has room for now
|
|
124
|
-
const sandbox = await agental.sandboxes.create({ size: "small", region: "eu" });
|
|
125
|
-
await agental.sandboxes.create({ size: "small", region: ["eu", "us"] }); // the first of them that has room
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
Left out, `region` is the default one. Each region has `name`, `label`, `about`, `default` and `sizesWithRoom`. A create that finds no room throws an `ApiError` with the status 409 and the code `capacity_exceeded`, whose `regionsWithRoom` names the other regions that have room for that size now, or none.
|
|
129
|
-
|
|
130
|
-
## When it stops
|
|
131
|
-
|
|
132
|
-
A sandbox runs until it is stopped, unless it has a time limit: then it is stopped once it has run for that long since its last start, and its disk is kept. The limit is `ttlSeconds`, in seconds: give it when you make a sandbox, on a template's start, or later.
|
|
133
|
-
|
|
134
|
-
```ts
|
|
135
|
-
const sandbox = await agental.sandboxes.create({ ttlSeconds: 7200 }); // two hours after each start
|
|
136
|
-
await sandbox.update({ ttlSeconds: 14_400 }); // four hours, from now on
|
|
137
|
-
|
|
138
|
-
// The run under way: when it stops, how long that is from now, and what decided it.
|
|
139
|
-
const { autoStopAt, secondsLeft, setBy } = await sandbox.autoStop(); // setBy "ttl": its last start and ttlSeconds
|
|
140
|
-
|
|
141
|
-
// Move that stop, later or sooner, for this run alone.
|
|
142
|
-
await sandbox.setAutoStop({ inSeconds: 3600 }); // an hour from now
|
|
143
|
-
await sandbox.setAutoStop({ at: new Date("2026-10-20T18:00:00Z") });
|
|
144
|
-
|
|
145
|
-
// Or take its time limit away: it runs until it is stopped.
|
|
146
|
-
await sandbox.clearAutoStop();
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
- The time limit comes from its create, its template, `update({ ttlSeconds })`, or a moved stop.
|
|
150
|
-
- Left out at a create, `ttlSeconds` is what the template's start says, and where that says none, the deployment's default. `null` is the deployment's default, whatever the template says. `(await agental.policy())` has `defaultTtlSeconds` and `maxTtlSeconds`: a limit over the ceiling throws an `ApiError` with the code `ttl_too_long`.
|
|
151
|
-
- A moved stop is 60 seconds from now at the least and `maxTtlSeconds` from now at the most, or it throws `auto_stop_too_soon` or `auto_stop_too_far` and the stop stays as it was. Each move counts from now, so a sandbox runs on for as long as something keeps moving its stop. A sandbox that is not running throws `invalid_state`.
|
|
152
|
-
- A move holds until the sandbox stops: its next start goes by `ttlSeconds` again. `update({ ttlSeconds })` while it runs puts the stop back to its last start and the new limit.
|
|
153
|
-
- `setBy` is `ttl`, `account` for a move by one of your keys or the console, or `sandbox` for a move by the sandbox itself. Every move is the event `sandbox.auto_stop_set`.
|
|
154
|
-
|
|
155
|
-
## Show what you built
|
|
156
|
-
|
|
157
|
-
```ts
|
|
158
|
-
const route = await sandbox.expose(8080);
|
|
159
|
-
console.log(route.url); // anyone can open it while the sandbox is running
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
The app must listen on `0.0.0.0`. Pass `{ public: false }` to keep the port behind your key.
|
|
163
|
-
|
|
164
|
-
## SSH
|
|
165
|
-
|
|
166
|
-
```ts
|
|
167
|
-
const ssh = await sandbox.ssh.on(); // where to connect: host, port, user, and the host key to pin
|
|
168
|
-
await sandbox.ssh.addKey("ssh-ed25519 AAAA... me@laptop"); // may log in as root
|
|
169
|
-
console.log(ssh.address, ssh.hostKey);
|
|
170
|
-
await sandbox.ssh.keys();
|
|
171
|
-
await sandbox.ssh.off(); // its port is closed
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
`sandbox.ssh.get()` reads it and changes nothing, and `removeKey(id)` takes a key away. SSH stays on through a stop, a start and a move, and `address` is null while the sandbox is not running. The command's `agental ssh <sandbox-id>` does all of it in one go.
|
|
175
|
-
|
|
176
|
-
## Desktop
|
|
177
|
-
|
|
178
|
-
```ts
|
|
179
|
-
const desktop = await sandbox.desktop.on(); // 1280 by 800 unless you give { width, height }
|
|
180
|
-
console.log(desktop.url); // watch and use it in a browser: keep it as a password is kept
|
|
181
|
-
|
|
182
|
-
await sandbox.desktop.click(640, 400);
|
|
183
|
-
await sandbox.desktop.type("hello");
|
|
184
|
-
await sandbox.desktop.key("ctrl+l");
|
|
185
|
-
const png = await sandbox.desktop.screenshot(); // the bytes of a PNG
|
|
186
|
-
const link = await sandbox.desktop.createLink({ access: "view", expiresIn: 600 }); // to show someone, for ten minutes
|
|
187
|
-
await sandbox.desktop.off();
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
A program in the sandbox opens its windows there with `DISPLAY` set to `desktop.display`. `screenshot` takes `format: "jpeg"`, a `region`, a `scale` and `cursor: false`, each cheaper for a model to read, and `screenshotBase64` gives the same picture as a model is handed it. `move`, `drag`, `scroll`, `clipboard` and `setClipboard` do what they say. What uses the pointer or the keyboard is never sent twice.
|
|
191
|
-
|
|
192
|
-
## Browser
|
|
193
|
-
|
|
194
|
-
A Chrome in the sandbox, driven from your own computer over the DevTools protocol:
|
|
195
|
-
|
|
196
|
-
```ts
|
|
197
|
-
import { chromium } from "playwright";
|
|
198
|
-
|
|
199
|
-
const { cdpUrl } = await sandbox.browser.on(); // on the desktop when one is on, or { headless: true }
|
|
200
|
-
const browser = await chromium.connectOverCDP(cdpUrl!);
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
Keep `cdpUrl` as a password is kept: whoever has it has the browser. Puppeteer, browser-use and Stagehand connect to it too, and `localCdpUrl` is the address from inside the sandbox. `sandbox.browser.get()` reads it, and `off()` cuts what was connected. What Chrome remembers stays on the sandbox's disk.
|
|
204
|
-
|
|
205
|
-
## Templates and secrets
|
|
206
|
-
|
|
207
|
-
A template is what a sandbox is made as and held to: its start (a size, a snapshot or a new machine, and a time limit), the variables and files every sandbox on it gets, and what every sandbox on it may reach (see below). Every account has one named `default`, which a sandbox is on when you name none.
|
|
208
|
-
|
|
209
|
-
```ts
|
|
210
|
-
const template = await agental.templates.create("coding-agent");
|
|
211
|
-
await template.secrets.set("GITHUB_TOKEN", process.env.GITHUB_TOKEN!);
|
|
212
|
-
await template.secrets.writeFile(".npmrc", "//registry.npmjs.org/:_authToken=...\n");
|
|
213
|
-
await template.setStart({ size: "medium" });
|
|
214
|
-
|
|
215
|
-
const sandbox = await agental.sandboxes.create({ template });
|
|
216
|
-
await sandbox.exec('gh auth status'); // GITHUB_TOKEN is in its environment
|
|
217
|
-
|
|
218
|
-
// A sandbox's own, on top of its template's, from its next command.
|
|
219
|
-
await sandbox.secrets.set("GITHUB_TOKEN", "a token for this one only");
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
A sandbox takes a change to its template when it next starts. To give it to the ones that are running now, apply it:
|
|
223
|
-
|
|
224
|
-
```ts
|
|
225
|
-
await template.secrets.set("GITHUB_TOKEN", "a new token");
|
|
226
|
-
console.log((await sandbox.template()).upToDate); // false
|
|
227
|
-
await template.apply(); // every running sandbox on it, or apply([sandbox.id])
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
`setStart({ size: "medium", ttlSeconds: 7200 })` has the template's sandboxes run for two hours after each start. `snapshotId` left out or `null` is a new machine. `ttlSeconds` left out stays as it was, and `null` is the deployment's default. Sandboxes made before keep their size, their disk and their time limit.
|
|
231
|
-
|
|
232
|
-
Values are read one at a time with `secrets.get(name)` and `secrets.readFile(path)`; `variables()` and `files()` give names and paths only. `sandbox.setTemplate(other)` puts a sandbox on another template, which it has from its next command. A template that sandboxes are on cannot be deleted.
|
|
233
|
-
|
|
234
|
-
## Keep a secret out of the sandbox
|
|
235
|
-
|
|
236
|
-
An agent can use a key without being able to read it. Set a variable with the hosts it is for, and it is kept out of the sandbox: its commands see a stand-in in its place, and when an HTTPS request leaves the sandbox for one of those hosts, the proxy on its server puts the value in place of the stand-in. Sent anywhere else, the stand-in is worth nothing.
|
|
237
|
-
|
|
238
|
-
```ts
|
|
239
|
-
const kept = await template.secrets.set("GITHUB_TOKEN", process.env.GITHUB_TOKEN!, { hosts: ["api.github.com", "github.com"] });
|
|
240
|
-
console.log(kept.standIn); // agental_hidden_..., what $GITHUB_TOKEN is in the sandbox
|
|
241
|
-
await template.apply(); // the running sandboxes on it take it now
|
|
242
|
-
|
|
243
|
-
// The request carries the stand-in out of the sandbox, and api.github.com is sent the token.
|
|
244
|
-
await sandbox.exec('curl -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/user');
|
|
245
|
-
|
|
246
|
-
// Fewer hosts, without sending the value again. A new host needs set() with the value.
|
|
247
|
-
await template.secrets.setHosts("GITHUB_TOKEN", ["api.github.com"]);
|
|
248
|
-
|
|
249
|
-
const variable = await template.secrets.variable("GITHUB_TOKEN");
|
|
250
|
-
if (variable.hidden) console.log(variable.hosts, variable.standIn);
|
|
251
|
-
else console.log(variable.value);
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
The value is never given back, to you or anyone: `variable(name)` gives a kept-out variable's hosts and stand-in, `get(name)` throws an `AgentalError` for one, and `hidden()` names the variables kept out. The stand-in stays the same when the value is replaced, so a file that holds it keeps working. Set again without hosts, a variable is plain, and its commands read its value: give its hosts each time you replace one. A host is a name such as `api.github.com`, or `*.example.com` for every name under `example.com`, with no scheme, port or path, and a variable has 1 to 10 of them. A name on the Public Suffix List, such as `github.io`, is refused, and so is a wildcard over one, such as `*.com`: each throws an `ApiError` with the code `invalid_host`. Hosts do not open the template's outbound rule. `(await agental.policy()).hiddenVariables` says whether variables can be kept out where you are: where they cannot, setting one with hosts throws an `ApiError` with the code `hidden_variables_unavailable`.
|
|
255
|
-
|
|
256
|
-
## Decide what it may reach
|
|
257
|
-
|
|
258
|
-
A sandbox reaches the internet unless its template says otherwise. The rule lives on the template, and every sandbox on it is held to it, enforced outside the sandbox, so nothing running in it can change it, root included. A sandbox can add addresses of its own on top:
|
|
259
|
-
|
|
260
|
-
```ts
|
|
261
|
-
// Nothing gets out of a sandbox on this template...
|
|
262
|
-
const template = await agental.templates.create("locked-down");
|
|
263
|
-
await template.setNetwork({ outbound: "blocked" });
|
|
264
|
-
|
|
265
|
-
// ...but this one may reach its own database, from its first boot.
|
|
266
|
-
const sandbox = await agental.sandboxes.create({ template, network: { allow: ["203.0.113.7"] } });
|
|
267
|
-
|
|
268
|
-
// Its own addresses, from now on. The list replaces the one it had, and [] takes them away.
|
|
269
|
-
await sandbox.setNetwork({ allow: ["203.0.113.7", "198.51.100.0/24"] });
|
|
270
|
-
|
|
271
|
-
const network = await sandbox.network();
|
|
272
|
-
console.log(network.outbound, network.allow); // what it is held to: "allowlist", [...]
|
|
273
|
-
console.log(network.template, network.own); // the template's rule as it has it, and its own addresses
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
The rule is `open`, `blocked`, or `allowlist` with the addresses in `allow`, and `setNetwork` on a template sends the whole rule each time. A sandbox's own addresses add to an allowlist, let it out to them alone on a blocked template, and change nothing while its template is open. Together they come to 100 addresses at most. `allow` takes IPv4 addresses and ranges, not names.
|
|
277
|
-
|
|
278
|
-
A sandbox made from a template is held to its rule from its first boot. A change to the rule reaches a sandbox already on it when it next starts, or now with `template.apply()`: its `failed` names any sandbox whose server did not take the new rule, which stays held to the one it had. A sandbox's own addresses hold from when `setNetwork` answers. Nothing is restarted, and your commands, files and ports still reach it. `(await agental.policy()).networkPolicy` says whether sandboxes can be held to a rule where you are: where they cannot, asking for `blocked`, `allowlist` or addresses of a sandbox's own throws an `ApiError` with the code `network_policy_unavailable`.
|
|
279
|
-
|
|
280
|
-
## See what it uses
|
|
281
|
-
|
|
282
|
-
A sandbox's CPU, memory, disk and network are read once a minute, from outside it, and kept 15 days. Nothing is installed in it:
|
|
283
|
-
|
|
284
|
-
```ts
|
|
285
|
-
// The last hour of one sandbox, oldest first.
|
|
286
|
-
const { points, complete } = await sandbox.metrics();
|
|
287
|
-
const last = points.at(-1);
|
|
288
|
-
console.log(last?.cpuUtilization, last?.memoryUsedBytes); // 0.42 is 42% of its CPUs
|
|
289
|
-
|
|
290
|
-
// Another stretch, with a point every five minutes.
|
|
291
|
-
await sandbox.metrics({ since: new Date(Date.now() - 86_400_000), stepSeconds: 300 });
|
|
292
|
-
|
|
293
|
-
// Every sandbox you have, at one moment. Without at, now.
|
|
294
|
-
const { sandboxes } = await agental.metrics({ at: "2026-10-14T09:35:00Z" });
|
|
295
|
-
for (const each of sandboxes) console.log(each.name, each.reading?.memoryUsedBytes);
|
|
296
|
-
|
|
297
|
-
// How many of them were running, step by step, over the last 24 hours.
|
|
298
|
-
const running = await agental.runningOverTime();
|
|
299
|
-
console.log(running.points.at(-1)?.running);
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
A sandbox has no point for a moment it was stopped at, and its `reading` is then null. A figure its server did not have is null too. `complete` is false when a server did not answer, and readings may then be missing. `(await agental.policy()).metrics` says whether sandboxes are read where you are: where they are not, both `metrics` calls throw an `ApiError` with the code `metrics_unavailable`. `runningOverTime` answers either way.
|
|
303
|
-
|
|
304
|
-
## What it costs
|
|
305
|
-
|
|
306
|
-
```ts
|
|
307
|
-
// Where the account stands, in millionths of a US dollar.
|
|
308
|
-
const billing = await agental.billing();
|
|
309
|
-
console.log(billing.usedMicros, billing.topUpMicros);
|
|
310
|
-
|
|
311
|
-
// What is sold: plans with the sizes each makes, top-ups and their bonus, the free month and the hourly prices.
|
|
312
|
-
const prices = await agental.prices();
|
|
313
|
-
|
|
314
|
-
// Start paying $500 for $537 of credits. A person pays at url: nothing is charged before.
|
|
315
|
-
const { url } = await agental.checkout({ topUp: 500 });
|
|
316
|
-
|
|
317
|
-
// When the credits run out, charge the saved card $50 for a top-up, before anything is stopped.
|
|
318
|
-
await agental.setAutoReload(50);
|
|
319
|
-
```
|
|
320
|
-
|
|
321
|
-
`usedMicros` is the month's usage at list price, spent from the plan's credits first and then from the top-ups, which do not expire. A top-up is any whole number of dollars from $15 to $100,000. From $200 it adds a bonus: 2% at $200, growing evenly on a log scale to 25% at $10,000 and over, so $1,000 adds $1,115. `prices.topUps` lists what each offered amount adds, and `prices.topUpBonus` the curve. `checkout({ plan: "plan100" })` starts a plan the same way, or moves an account that has one at once, with `url` null and `changed` true. A checkout whose answer is lost is not sent again.
|
|
322
|
-
|
|
323
|
-
`setAutoReload(dollars)` has the card saved at the last purchase charged for a top-up of that much, with tax, when the month's credits and the top-ups run out, and the credits added at once, before any sandbox is stopped: at most three times a day, and not again for a day after a card that would not pay. `setAutoReload(null)` turns it off, and `billing().autoReload` says how it is set. It needs a plan or a top-up bought first, which saves the card: `billing().canAutoReload` says whether it can be set, and the call answers `409 no_card` until then.
|
|
324
|
-
|
|
325
|
-
The $20 plan makes tiny and small sandboxes, the $100 plan medium ones too, and the $500 and $2000 plans every size: `prices.tiers` lists the plans, and each plan's `sizes` says which. A create, fork or template start of a size the plan does not make throws an `ApiError` with the status 402 and the code `size_not_on_plan`, whose message names the plan that makes it. A sandbox made before a change of plan still starts at its own size.
|
|
326
|
-
|
|
327
|
-
## How many you run, and how fast you start them
|
|
328
|
-
|
|
329
|
-
Each plan runs so many small sandboxes at once, its running count, and starts so many at once for nothing, its free burst: a tiny counts half, a medium 2 and a large 4. The $20 plan runs 20 and bursts 20, the $100 plan 100 and 100, the $500 plan 600 and 200, the $2000 plan 2000 and 400; `prices().tiers` gives each plan's `runningUnits` and `freeBurstUnits`, and `burstFee` how fees are worked out. Each start adds its memory to a burst meter that halves every 15 minutes. Starting faster than the free burst is never refused for speed: it costs a burst fee, which grows with how far over the meter goes.
|
|
330
|
-
|
|
331
|
-
```ts
|
|
332
|
-
const burst = await agental.burst();
|
|
333
|
-
console.log(burst.runningUnits, burst.freeBurstUnits); // 100 100
|
|
334
|
-
console.log(burst.freeNowMb / 4096); // small sandboxes you may start now for nothing
|
|
335
|
-
console.log(burst.spentMicros, burst.budgetMicros); // burst fees this month, and the most you spend on them
|
|
336
|
-
|
|
337
|
-
// What starting 30 medium ones now would cost, and in how many seconds it would cost nothing.
|
|
338
|
-
const { quote } = await agental.burst({ size: "medium", count: 30 });
|
|
339
|
-
console.log(quote?.feeMicros, quote?.freeInSeconds);
|
|
340
|
-
|
|
341
|
-
// Spend at most $10 a month on burst fees; 0 refuses every start that would cost one, null puts back the default ($50).
|
|
342
|
-
await agental.setBurstBudget(10_000_000);
|
|
343
|
-
```
|
|
344
|
-
|
|
345
|
-
A create, fork or start takes `maxBurstFeeMicros`, the most it may pay, and the sandbox's `burstFeeMicros` says what it paid:
|
|
346
|
-
|
|
347
|
-
```ts
|
|
348
|
-
const sandbox = await agental.sandboxes.create({ maxBurstFeeMicros: 50_000 }); // at most 5 cents
|
|
349
|
-
console.log(sandbox.burstFeeMicros); // 0 within the free burst
|
|
350
|
-
await sandbox.start({ maxBurstFeeMicros: 0, maxBurstWaitMs: 300_000 }); // free, waiting up to 5 minutes for it to be
|
|
351
|
-
```
|
|
352
|
-
|
|
353
|
-
Where `enforced` is true, a start is refused with an `ApiError` of status 429 and one of these codes. `feeMicros` says what it would have cost and `retryAfterSeconds` when it would cost nothing, or null when waiting would not help:
|
|
354
|
-
|
|
355
|
-
- `running_limit_reached`: the account already runs its plan's running count. Stop something first.
|
|
356
|
-
- `burst_fee_too_high`: the start would cost more than `maxBurstFeeMicros`.
|
|
357
|
-
- `burst_budget_reached`: the fee would take this month's burst fees past the budget.
|
|
358
|
-
- `burst_in_free_month`: the start would cost a fee, which the free month does not pay.
|
|
359
|
-
|
|
360
|
-
With `maxBurstWaitMs`, a start refused with one of the last three waits `retryAfterSeconds`, if that is no longer than allowed, and is tried once more. It is off by default, and never waits for `running_limit_reached`. Burst fees come out of the plan's credits, then the top-ups, and `billing().burstMicros` is this month's.
|
|
361
|
-
|
|
362
|
-
## Send it to your own collector
|
|
363
|
-
|
|
364
|
-
Your sandboxes' metrics and lifecycle events can be sent to an OTLP/HTTP collector of your own:
|
|
365
|
-
|
|
366
|
-
```ts
|
|
367
|
-
// Set it, or replace the one you had. Nothing is sent yet.
|
|
368
|
-
await agental.setTelemetry({ endpoint: "https://otlp.example.com", headers: { "x-api-key": "..." } });
|
|
369
|
-
|
|
370
|
-
// Send it one empty export, and see what it answered.
|
|
371
|
-
console.log(await agental.testTelemetry()); // { ok: true, status: 200, message: null }
|
|
372
|
-
|
|
373
|
-
// Where it is, the names of its headers, and how the last export went. Null when none is set.
|
|
374
|
-
const telemetry = await agental.getTelemetry();
|
|
375
|
-
console.log(telemetry?.headers, telemetry?.lastExportAt, telemetry?.lastError);
|
|
376
|
-
|
|
377
|
-
// Stop sending to it.
|
|
378
|
-
await agental.deleteTelemetry();
|
|
379
|
-
```
|
|
380
|
-
|
|
381
|
-
`endpoint` is the collector's base address, https on port 443 or 4318: metrics go to `/v1/metrics` under it, every minute, and events to `/v1/logs`, as they happen. The values of `headers` are kept and never shown again. An event has the same id in the collector as in your webhooks. `deleteTelemetry` and `testTelemetry` throw an `ApiError` with the status 404 when none is set. `(await agental.policy()).telemetry` says whether anything is sent to a collector where you are: where it is not, these calls throw an `ApiError` with the code `telemetry_unavailable`. Metrics are sent only where `metrics` is true as well.
|
|
382
|
-
|
|
383
|
-
## What agental keeps about you
|
|
384
|
-
|
|
385
|
-
```ts
|
|
386
|
-
import { writeFile } from "node:fs/promises";
|
|
387
|
-
|
|
388
|
-
const exported = await agental.exportAccount();
|
|
389
|
-
console.log(exported.account.email, exported.sessions.length);
|
|
390
|
-
await writeFile("agental-account.json", JSON.stringify(exported, null, 2));
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
It is everything agental keeps about your account and the person behind it: the account, its sessions with the address and browser each began from, its keys by name, billing, payments, usage, and what it has made. Secrets are named, never given. It works with a key. Closing an account does not: only a person signed in to the console can close one, from Settings.
|
|
394
|
-
|
|
395
|
-
## How long history is kept
|
|
396
|
-
|
|
397
|
-
Your sandboxes' history is the output of their commands, their agent conversations and their events. It is kept 30 days until you choose:
|
|
398
|
-
|
|
399
|
-
```ts
|
|
400
|
-
const retention = await agental.getDataRetention();
|
|
401
|
-
console.log(retention.keepDays, retention.retained); // 30, and what agental keeps for periods of its own
|
|
402
|
-
await agental.setDataRetention(7); // 1, 7, 30 or 90 days, or null until the sandbox is deleted
|
|
403
|
-
```
|
|
404
|
-
|
|
405
|
-
History older than that is deleted within the hour and cannot be brought back. A deleted sandbox's history goes with it, whatever is set.
|
|
406
|
-
|
|
407
|
-
## Email
|
|
408
|
-
|
|
409
|
-
Every sandbox has a mail domain of its own, and any name before the `@` reaches it. Mail is off until you turn it on. One switch covers receiving and sending.
|
|
410
|
-
|
|
411
|
-
```ts
|
|
412
|
-
const { domain } = await sandbox.email.enable();
|
|
413
|
-
console.log(`support@${domain}`); // support@sbx-3f9a1c2b7d4e5a60.k7m2x9qd.agental.email
|
|
414
|
-
```
|
|
415
|
-
|
|
416
|
-
A message that arrives is kept for 7 days and is read here, whether the sandbox is running, stopped or archived. It never enters the machine.
|
|
417
|
-
|
|
418
|
-
```ts
|
|
419
|
-
const [newest] = (await sandbox.email.list({ limit: 1 })).messages; // newest first
|
|
420
|
-
if (newest) {
|
|
421
|
-
const message = await sandbox.email.get(newest.id);
|
|
422
|
-
console.log(message.from, message.subject, message.text);
|
|
423
|
-
}
|
|
424
|
-
```
|
|
425
|
-
|
|
426
|
-
`sandbox.email.all()` walks every page, `sandbox.email.raw(id)` is the message as it was transmitted, attachments included, and `sandbox.email.delete(id)` deletes one.
|
|
427
|
-
|
|
428
|
-
A sandbox can write to one address only: the sign-in address of its own account. So a send names nobody.
|
|
429
|
-
|
|
430
|
-
```ts
|
|
431
|
-
const sent = await sandbox.email.send({ subject: "The build passed", text: "All 812 tests passed." });
|
|
432
|
-
console.log(sent.to, sent.state);
|
|
433
|
-
```
|
|
434
|
-
|
|
435
|
-
- The message is plain text. `from` is the part before the `@`, `sandbox` unless you say, and `fromName` the name beside it. `attachments` are `{ filename, contentType, contentBase64 }`. `replyTo` is the id of a message the sandbox received: it threads the answer under it, and gives no right to write to whoever sent it.
|
|
436
|
-
- `state` says where a sent message has got to: read it again with `get`. `delivered` means the recipient's server took it, which says nothing of inbox or spam folder.
|
|
437
|
-
- A send carries an `Idempotency-Key`, as a create does: if its answer is lost it is asked again under the same key, and no second message is sent. Pass `idempotencyKey` yourself to be able to ask again after your process has died.
|
|
438
|
-
- Each message, received or sent, is charged to the account, and `usage()` counts them as `mail`. `agental.email()` has the account's limits and what it has had of them today.
|
|
439
|
-
- `(await agental.policy()).email` says whether mail is offered where you are. A refusal is an `ApiError` with the API's code: `mail_off`, `recipient_not_allowed`, `mail_limit_reached`, `mail_not_configured`.
|
|
440
|
-
|
|
441
|
-
A message is text written by whoever sent it, which may be anyone. A program that reads it should treat it as something said to it, never as something it was told to do. Attachments are not scanned. Give an agent a key that can do nothing else, not an account key: one made with the actions `email.read` and `email.send` on its one sandbox, which can read and send that sandbox's mail and nothing more. It cannot read the sandbox itself, so reach the mail by the sandbox's id:
|
|
442
|
-
|
|
443
|
-
```ts
|
|
444
|
-
const { secret } = await agental.apiKeys.create({ name: "mail-agent", actions: ["email.read", "email.send"], sandboxIds: ["sbx_3f9a1c2b7d4e5a60"] });
|
|
445
|
-
const mail = new Agental({ apiKey: secret }).sandboxes.email("sbx_3f9a1c2b7d4e5a60");
|
|
446
|
-
const { messages } = await mail.list();
|
|
447
|
-
```
|
|
448
|
-
|
|
449
|
-
## What the SDK does for you
|
|
450
|
-
|
|
451
|
-
- A create or a fork is sent with an `Idempotency-Key`. If its answer is lost, or the machine is not confirmed yet, the same request is sent again with the same key, which gives the same sandbox back and never a second one. Pass `idempotencyKey` yourself to be able to ask again after your process has died.
|
|
452
|
-
- Reads, file writes, starts and stops are sent again when a proxy answers in the API's place, when the connection drops, and when the sandbox was moved to another server mid-request.
|
|
453
|
-
- A command is never sent twice. One that was cut off may have done part of its work, so that is yours to decide.
|
|
454
|
-
- A delete carries the confirm header the API asks for.
|
|
455
|
-
|
|
456
|
-
## Errors
|
|
457
|
-
|
|
458
|
-
```ts
|
|
459
|
-
import { ApiError, ProxyError, TimeoutError } from "agental";
|
|
460
|
-
|
|
461
|
-
try {
|
|
462
|
-
await sandbox.exec("make");
|
|
463
|
-
} catch (error) {
|
|
464
|
-
if (error instanceof ApiError && error.code === "sandbox_not_running") {
|
|
465
|
-
await sandbox.start();
|
|
466
|
-
} else {
|
|
467
|
-
throw error;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
- `ApiError` is the API's own refusal, with `status`, `code` and `message`. Decide from `code`. A start refused for its burst also has `feeMicros` and `retryAfterSeconds`: see [How many you run](#how-many-you-run-and-how-fast-you-start-them).
|
|
473
|
-
- `ProxyError` is a 502, a 504 or any error with no code in it: something in front of the API answered, and the request may still be running.
|
|
474
|
-
- `ConnectionError` is the API not being reached. `TimeoutError` is a wait that ran out.
|
|
475
|
-
|
|
476
|
-
All of them are `AgentalError`.
|
|
477
|
-
|
|
478
|
-
## Webhooks
|
|
479
|
-
|
|
480
|
-
```ts
|
|
481
|
-
const hook = await agental.createWebhook({ url: "https://example.com/hooks/agental", events: ["sandbox.stopped", "sandbox.error"] });
|
|
482
|
-
hook.signingSecret; // given this once
|
|
483
|
-
await agental.listWebhooks(); // each with its tries of the last 30 days
|
|
484
|
-
await agental.getWebhook(hook.id);
|
|
485
|
-
await agental.updateWebhook(hook.id, { events: ["*"] }); // every event, and any added later
|
|
486
|
-
await agental.rotateWebhookSecret(hook.id); // a new signingSecret; the old one stops working
|
|
487
|
-
await agental.deleteWebhook(hook.id);
|
|
488
|
-
```
|
|
489
|
-
|
|
490
|
-
`events` takes the kinds of event, such as `sandbox.started`, `sandbox.stopped` and `sandbox.error`. `sandbox.auto_stop_set` is a running sandbox's stop moved: its `data` has `autoStopAt`, `previousAutoStopAt`, and `by`, `account` or `sandbox`.
|
|
491
|
-
|
|
492
|
-
Check each post your server receives:
|
|
493
|
-
|
|
494
|
-
```ts
|
|
495
|
-
import { verifyWebhook } from "agental";
|
|
496
|
-
|
|
497
|
-
const genuine = await verifyWebhook(rawBody, request.headers.get("x-agental-signature"), signingSecret);
|
|
498
|
-
```
|
|
499
|
-
|
|
500
|
-
Pass the body exactly as it arrived, before any JSON parsing.
|
|
501
|
-
|
|
502
|
-
## API keys
|
|
503
|
-
|
|
504
|
-
A key does what it was made to do and nothing else: a preset, or the actions it may do named one by one. It can be limited to some sandboxes and templates, to a date, and to the addresses it is used from.
|
|
505
|
-
|
|
506
|
-
```ts
|
|
507
|
-
const key = await agental.apiKeys.create({
|
|
508
|
-
name: "ci-github",
|
|
509
|
-
preset: "ci", // use sandboxes, make, fork and resume them, and delete the ones it made
|
|
510
|
-
templateIds: ["tpl_coding_agent"], // these templates and the sandboxes on them
|
|
511
|
-
allowFrom: ["203.0.113.0/24", "2001:db8::/48"], // refused from anywhere else
|
|
512
|
-
expiresAt: new Date(Date.now() + 30 * 86_400_000), // optional: when it stops working
|
|
513
|
-
});
|
|
514
|
-
key.secret; // given this once
|
|
515
|
-
|
|
516
|
-
await agental.apiKeys.list(); // never with their secrets
|
|
517
|
-
await agental.apiKeys.get(key.id); // with when and from where it was last used
|
|
518
|
-
await agental.apiKeys.setAllowFrom(key.id, []); // anywhere: the one thing that changes after it is made
|
|
519
|
-
await agental.apiKeys.usage(key.id); // what the sandboxes it made ran, at list price
|
|
520
|
-
await agental.apiKeys.delete(key.id);
|
|
521
|
-
```
|
|
522
|
-
|
|
523
|
-
- The presets are `read-only`, `full-sandbox`, `ci` and `admin`. Send `preset` or `actions`, not both. `actions` takes names such as `sandbox.read`, `exec` and `email.send` (the type `ApiKeyAction` lists them all), or `"*"` for everything.
|
|
524
|
-
- `sandbox.auto-stop` reads and moves when a running sandbox is to be stopped. It is in the `full-sandbox` and `ci` presets, but not in a key made with one before it was added: a preset is written out into a key's actions when the key is made.
|
|
525
|
-
- A key always reaches the sandboxes it makes. One limited to several templates, none of them your default, has to name a template when it makes a sandbox, or it is refused with `template_required`.
|
|
526
|
-
- A key used to make another makes only a weaker one: no action, sandbox or template it lacks, no later expiry, and addresses inside its own. A stronger one is refused with `key_exceeds_caller`.
|
|
527
|
-
- A key is refused with `key_action_forbidden` for what it was not made to do, `key_address_forbidden` from an address not on its list, and `key_expired` once it has expired.
|
|
528
|
-
|
|
529
|
-
`allowFrom` is for keys used outside agental, such as from CI or your own servers. A sandbox reaches the API from its server's shared IPv4 address, which changes when the sandbox moves, so limit a key used inside a sandbox with `sandboxIds` instead. On a network with IPv6 the API sees your IPv6 address, so a list of IPv4 addresses alone refuses you there.
|
|
530
|
-
|
|
531
|
-
## Options
|
|
532
|
-
|
|
533
|
-
```ts
|
|
534
|
-
new Agental({
|
|
535
|
-
apiKey: "...", // default: AGENTAL_API_KEY, then the agental command's sign-in
|
|
536
|
-
baseUrl: "https://agental.io", // default: AGENTAL_API_URL, then the API the command signed in to, then this
|
|
537
|
-
maxRetries: 3, // how many times a request that is safe to send again is sent again
|
|
538
|
-
retryDelayMs: 1000, // the shortest wait before it is
|
|
539
|
-
pollIntervalMs: 2000, // how often something waited for is looked at
|
|
540
|
-
fetch: myFetch,
|
|
541
|
-
});
|
|
542
|
-
```
|
|
11
|
+
`npx agental@latest` alone signs in through your browser, keeps a key for this computer in `~/.config/agental/credentials.json`, installs the command with `npm install -g agental`, and installs the agent skills for the coding agents it finds here. `agental help` lists every command: `agental create`, `agental exec`, `agental ssh` and the rest.
|
|
543
12
|
|
|
544
|
-
|
|
13
|
+
Node 20.16 or later. The full guide is at [agental.io/docs/cli](https://agental.io/docs/cli).
|
|
545
14
|
|
|
546
|
-
|
|
15
|
+
For the SDK, to use agental from your own code, install [agental-sdk](https://www.npmjs.com/package/agental-sdk) instead: `npm install agental-sdk`. It uses the same sign-in when no key is given. Up to 0.1.0 this package held the SDK too; from 0.2.0 it is the command alone, and `import ... from "agental"` is `from "agental-sdk"`.
|
package/bin/agental.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The `agental` command. npm checks that a package's bin exists before prepublishOnly builds
|
|
3
3
|
// dist/, and drops one that does not: so the bin is this file, which is always there, and the
|
|
4
|
-
// command itself is dist/cli.js, bundled by scripts/bundle-cli.mjs.
|
|
4
|
+
// command itself is dist/cli.js, bundled from src/cli.ts by scripts/bundle-cli.mjs.
|
|
5
5
|
import "../dist/cli.js";
|