agental-sdk 0.1.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .mypy_cache/
4
+ __pycache__/
5
+ dist/
6
+ uv.lock
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VERTIXER LTD
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,633 @@
1
+ Metadata-Version: 2.5
2
+ Name: agental-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for agental.io: Linux sandboxes over HTTP.
5
+ Project-URL: Homepage, https://agental.io
6
+ Project-URL: Documentation, https://agental.io/docs/python
7
+ Project-URL: Source, https://github.com/agental-io/agental-python
8
+ Project-URL: Issues, https://github.com/agental-io/agental-python/issues
9
+ Author: VERTIXER LTD
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agental,agents,sandbox,virtual-machine
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: httpx<1,>=0.27
26
+ Description-Content-Type: text/markdown
27
+
28
+ # agental
29
+
30
+ Python SDK for [agental.io](https://agental.io): a Linux virtual machine of your own over HTTP. One call makes it, another runs a command in it, another stops it. Its disk is kept while it is stopped.
31
+
32
+ ```bash
33
+ pip install agental-sdk
34
+ ```
35
+
36
+ Then `from agental import Agental`. Python 3.10 or later. It depends on `httpx` and nothing else.
37
+
38
+ ## Start
39
+
40
+ Make an API key in the console and put it in `AGENTAL_API_KEY`.
41
+
42
+ ```python
43
+ from agental import Agental
44
+
45
+ agental = Agental()
46
+
47
+ sandbox = agental.sandboxes.create(size="small") # tiny, small, medium or large
48
+
49
+ result = sandbox.exec("uname -a")
50
+ print(result.exit_code, result.stdout)
51
+
52
+ sandbox.files.write("app/main.py", "print('hello')\n")
53
+ print(sandbox.exec(["python3", "/opt/agental/app/main.py"]).stdout)
54
+
55
+ sandbox.delete()
56
+ ```
57
+
58
+ `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.
59
+
60
+ ## asyncio
61
+
62
+ `AsyncAgental` is the same client, awaited.
63
+
64
+ ```python
65
+ import asyncio
66
+ from agental import AsyncAgental
67
+
68
+ async def main() -> None:
69
+ async with AsyncAgental() as agental:
70
+ sandbox = await agental.sandboxes.create()
71
+ print((await sandbox.exec("uname -a")).stdout)
72
+ await sandbox.delete()
73
+
74
+ asyncio.run(main())
75
+ ```
76
+
77
+ ## Commands
78
+
79
+ ```python
80
+ sandbox.exec(["ls", "-la", "/opt/agental"]) # the arguments exactly, no shell
81
+ sandbox.exec("cd /opt/agental && npm test") # a line of shell, run by sh -c
82
+ ```
83
+
84
+ - A command that fails is an answer, not an error: read `exit_code`.
85
+ - Every command starts fresh, as root in `/root`. A `cd` or an `export` does not carry over to the next one.
86
+ - A command gets 60 seconds unless you pass `timeout`, in seconds, up to 90. One that runs out is killed and exits `124`. For longer, use `commands.run` below, up to 600.
87
+ - Each of `stdout` and `stderr` is cut at 1 MB, and `truncated` is then true.
88
+
89
+ To see output as it arrives:
90
+
91
+ ```python
92
+ for event in sandbox.exec_stream("npm test"):
93
+ if event.type == "exit":
94
+ print("exited", event.exit_code)
95
+ else:
96
+ print(event.data, end="")
97
+ ```
98
+
99
+ For a command that takes more than a minute, do not hold a request open for it:
100
+
101
+ ```python
102
+ build = sandbox.commands.run("cd /opt/agental && make", timeout=600)
103
+ print(build.status, build.exit_code, build.stdout)
104
+
105
+ # or start it and look later
106
+ started = sandbox.commands.start("make")
107
+ now = sandbox.commands.get(started.id)
108
+ ```
109
+
110
+ 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 &")`.
111
+
112
+ ## On a schedule
113
+
114
+ ```python
115
+ tick = sandbox.schedules.create("python scrape.py", every="1h", name="scrape")
116
+ sandbox.schedules.create("./digest.sh", cron="0 7 * * 1-5", time_zone="Europe/Sofia")
117
+
118
+ sandbox.schedules.run(tick.id) # one run now
119
+ runs = sandbox.schedules.runs(tick.id) # the latest first: outcome, exit_code, command_id
120
+ sandbox.schedules.update(tick.id, enabled=False) # paused
121
+ ```
122
+
123
+ - 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. `keep_memory=True` keeps its memory too. `if_stopped="skip"` runs only while it runs.
124
+ - A sandbox someone started again, sent a command or moved the stop of meanwhile is left running.
125
+ - 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.
126
+ - After 10 runs one after another fail, a schedule pauses itself and says why in `paused_reason`; resuming it clears that.
127
+ - While a run still goes, the next is skipped. `overlap="replace"` stops it instead, `"parallel"` runs both.
128
+ - A run gets 10 minutes unless `timeout` says, in seconds, up to six hours. One that could not be made on time is `missed`, and never made up later.
129
+
130
+ ## Files
131
+
132
+ Paths are relative to the work folder, `/opt/agental`. A file can be up to 10 MB.
133
+
134
+ ```python
135
+ sandbox.files.write("notes.txt", "hi")
136
+ sandbox.files.read("notes.txt") # "hi"
137
+ sandbox.files.write("logo.png", data) # bytes
138
+ sandbox.files.read_bytes("logo.png")
139
+ ```
140
+
141
+ ## Stop, start, fork, snapshot
142
+
143
+ ```python
144
+ sandbox.stop() # the disk is kept, what was running is not, and it costs 5% of the running price
145
+ sandbox.stop(keep_memory=True) # or keep its memory too, at 10%: start() puts back every process as it was
146
+ sandbox.start() # waits until it is running, also when it has to be brought back from storage
147
+
148
+ copy = sandbox.fork() # a new sandbox with the same disk
149
+
150
+ snapshot = sandbox.snapshot("before-upgrade")
151
+ sandbox.restore(snapshot.id) # roll the disk back
152
+ fresh = agental.sandboxes.create(snapshot_id=snapshot.id)
153
+ ```
154
+
155
+ ## Regions
156
+
157
+ A sandbox is made in one region and stays there. `eu` has servers in the European Economic Area or the United Kingdom; `us` and `apac` are the others, where they have servers.
158
+
159
+ ```python
160
+ from agental import ApiError
161
+
162
+ for region in agental.regions(): # the regions you can make sandboxes in now, the default first
163
+ print(region.name, region.label, region.default, region.sizes_with_room)
164
+
165
+ sandbox = agental.sandboxes.create(region="eu")
166
+ sandbox = agental.sandboxes.create(region=["eu", "us"]) # the first of them that has room
167
+
168
+ try:
169
+ agental.sandboxes.create(size="large", region="us")
170
+ except ApiError as error:
171
+ if error.code != "capacity_exceeded":
172
+ raise
173
+ print(error.regions_with_room) # where a sandbox of that size fits now, maybe []
174
+ ```
175
+
176
+ Left out, a sandbox is made in the default region, or in the region of the snapshot it is made from. A full region raises an `ApiError` with the status 409 and the code `capacity_exceeded`, and the sandbox is not made anywhere else unless you named more than one region. `regions_with_room` says where it would fit now: create it again there.
177
+
178
+ ## When it stops
179
+
180
+ A sandbox is stopped once it has run for its time limit since its last start, and its disk is kept. The limit is `ttl_seconds`, in seconds: give it when you make a sandbox, on a template's start, or later.
181
+
182
+ ```python
183
+ from datetime import datetime, timezone
184
+
185
+ sandbox = agental.sandboxes.create(ttl_seconds=7200) # two hours after each start
186
+ sandbox.update(ttl_seconds=14_400) # four hours, from now on
187
+
188
+ # The run under way: when it stops, how long that is from now, and what decided it.
189
+ stop = sandbox.auto_stop()
190
+ print(stop.auto_stop_at, stop.seconds_left, stop.set_by) # set_by "ttl": its last start and ttl_seconds
191
+
192
+ # Move that stop, later or sooner, for this run alone.
193
+ sandbox.set_auto_stop(in_seconds=3600) # an hour from now
194
+ sandbox.set_auto_stop(at=datetime(2026, 10, 20, 18, 0, tzinfo=timezone.utc))
195
+
196
+ # Or take its time limit away: it runs until it is stopped.
197
+ sandbox.clear_auto_stop()
198
+ ```
199
+
200
+ - A sandbox runs until it is stopped, unless it has a time limit: from its create, its template, `update(ttl_seconds=...)`, or a moved stop.
201
+
202
+ - Left out at a create, `ttl_seconds` is what the template's start says, and where that says none, the deployment's default. `None` is the deployment's default, whatever the template says. `agental.policy()` has `default_ttl_seconds` and `max_ttl_seconds`: a limit over the ceiling raises an `ApiError` with the code `ttl_too_long`.
203
+ - Give `set_auto_stop` either `at`, a `datetime` with its time zone or an ISO 8601 string with its offset, or `in_seconds`, and not both: anything else raises a `ValueError` before it is sent. A moved stop is 60 seconds from now at the least and `max_ttl_seconds` from now at the most, or it raises `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 raises `invalid_state`.
204
+ - A move holds until the sandbox stops: its next start goes by `ttl_seconds` again. `update(ttl_seconds=...)` while it runs puts the stop back to its last start and the new limit.
205
+ - `set_by` 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`.
206
+
207
+ ## Show what you built
208
+
209
+ ```python
210
+ route = sandbox.expose(8080)
211
+ print(route.url) # anyone can open it while the sandbox is running
212
+ ```
213
+
214
+ The app must listen on `0.0.0.0`. Pass `public=False` to keep the port behind your key.
215
+
216
+ ## SSH
217
+
218
+ ```python
219
+ sandbox = agental.sandboxes.create(ssh_keys=[open("/home/me/.ssh/id_ed25519.pub").read()])
220
+ ssh = sandbox.ssh.on()
221
+ print(f"ssh -p {ssh.port} {ssh.user}@{ssh.host}") # user is root
222
+ print(ssh.host_key) # pin it in known_hosts
223
+
224
+ sandbox.ssh.add_key("ssh-ed25519 AAAA... me@laptop") # the sandbox must be running
225
+ for key in sandbox.ssh.keys():
226
+ print(key.id, key.fingerprint, key.comment)
227
+ sandbox.ssh.remove_key(key.id)
228
+
229
+ sandbox.ssh.off() # closes the port and cuts whoever is connected
230
+ ```
231
+
232
+ SSH stays on through a stop and a start. `ssh_keys` at a create lets the keys in, but turns nothing on: `ssh.on()` does, or its template. A template can do both for every sandbox made from it: `template.ssh_on()` and `template.add_ssh_key(line)`. The agental command's `agental ssh` does all of it in one go. `agental.policy().ssh` says whether sandboxes can be reached over SSH where you are.
233
+
234
+ ## Desktop
235
+
236
+ A sandbox can have a screen of its own, which programs open windows on and which you watch and use in a browser.
237
+
238
+ ```python
239
+ desktop = sandbox.desktop.on() # 1280 by 800 unless you pass width and height
240
+ print(desktop.url) # keep it as a password is kept
241
+ print(desktop.display) # what DISPLAY is for a command that opens a window on it
242
+
243
+ png = sandbox.desktop.screenshot() # bytes; format="jpeg", region=(x, y, w, h) and scale make it smaller
244
+ shot = sandbox.desktop.screenshot_base64(scale=0.5) # for a model: base64, its size and the part it shows
245
+ sandbox.desktop.click(640, 400)
246
+ sandbox.desktop.type("hello")
247
+ sandbox.desktop.key("ctrl+s")
248
+
249
+ link = sandbox.desktop.create_link("view", expires_in=600) # watch only, for ten minutes
250
+ print(link.url)
251
+
252
+ sandbox.desktop.off() # closes what was open on it
253
+ ```
254
+
255
+ `move`, `drag` and `scroll` move the pointer, and `clipboard()` and `set_clipboard(text)` read and fill the clipboard. A click, a key or typing is never sent twice by itself. `agental.policy().desktop` says whether a sandbox can have a desktop where you are.
256
+
257
+ ## Browser
258
+
259
+ Chrome in the sandbox, driven from your own computer over the Chrome DevTools Protocol:
260
+
261
+ ```python
262
+ from playwright.sync_api import sync_playwright
263
+
264
+ browser = sandbox.browser.on() # no window, or on the desktop when one is on
265
+ with sync_playwright() as playwright:
266
+ chrome = playwright.chromium.connect_over_cdp(browser.cdp_url)
267
+ page = chrome.contexts[0].new_page()
268
+ page.goto("https://example.com")
269
+
270
+ sandbox.browser.off() # cdp_url leads nowhere from then on
271
+ ```
272
+
273
+ Keep `cdp_url` as a password is kept: whoever has it has the browser, and what it is signed in to. `on(headless=True)` or `on(headless=False)` says whether it has a window; asked otherwise than it is, Chrome is started again, which closes what was open in it. `local_cdp_url` reaches it from inside the sandbox. What Chrome remembers stays on the sandbox's disk. `agental.policy().browser` says whether a sandbox's browser can be driven where you are.
274
+
275
+ ## Templates and secrets
276
+
277
+ 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.
278
+
279
+ ```python
280
+ template = agental.templates.create("coding-agent")
281
+ template.secrets.set("GITHUB_TOKEN", os.environ["GITHUB_TOKEN"])
282
+ template.secrets.write_file(".npmrc", "//registry.npmjs.org/:_authToken=...\n")
283
+ template.set_start("medium")
284
+
285
+ sandbox = agental.sandboxes.create(template=template)
286
+ sandbox.exec("gh auth status") # GITHUB_TOKEN is in its environment
287
+
288
+ # A sandbox's own, on top of its template's, from its next command.
289
+ sandbox.secrets.set("GITHUB_TOKEN", "a token for this one only")
290
+ ```
291
+
292
+ A sandbox takes a change to its template when it next starts. To give it to the ones that are running now, apply it:
293
+
294
+ ```python
295
+ template.secrets.set("GITHUB_TOKEN", "a new token")
296
+ print(sandbox.template().up_to_date) # False
297
+ template.apply() # every running sandbox on it, or apply([sandbox.id])
298
+ ```
299
+
300
+ `set_start("medium", ttl_seconds=7200)` has the template's sandboxes run for two hours after each start. `snapshot_id` left out or `None` is a new machine. `ttl_seconds` left out stays as it was, and `None` is the deployment's default. Sandboxes made before keep their size, their disk and their time limit.
301
+
302
+ Values are read one at a time with `secrets.get(name)` and `secrets.read_file(path)`; `variables()` and `files()` give names and paths only. `sandbox.set_template(other)` puts a sandbox on another template, which it has from its next command. A template that sandboxes are on cannot be deleted.
303
+
304
+ ## Keep a secret out of the sandbox
305
+
306
+ 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.
307
+
308
+ ```python
309
+ from agental import HiddenVariable
310
+
311
+ kept = template.secrets.set("GITHUB_TOKEN", os.environ["GITHUB_TOKEN"], hosts=["api.github.com", "github.com"])
312
+ print(kept.stand_in) # agental_hidden_..., what $GITHUB_TOKEN is in the sandbox
313
+ template.apply() # the running sandboxes on it take it now
314
+
315
+ # The request carries the stand-in out of the sandbox, and api.github.com is sent the token.
316
+ sandbox.exec('curl -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/user')
317
+
318
+ # Fewer hosts, without sending the value again. A new host needs set() with the value.
319
+ template.secrets.set_hosts("GITHUB_TOKEN", ["api.github.com"])
320
+
321
+ variable = template.secrets.variable("GITHUB_TOKEN")
322
+ if isinstance(variable, HiddenVariable):
323
+ print(variable.hosts, variable.stand_in)
324
+ else:
325
+ print(variable.value)
326
+ ```
327
+
328
+ The value is never given back, to you or anyone: `variable(name)` gives a kept-out variable as a `HiddenVariable`, with its hosts and stand-in, and a plain one as a `PlainVariable`, with its value. `get(name)` raises an `AgentalError` for one kept out, 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 raises an `ApiError` with the code `invalid_host`. Hosts do not open the template's outbound rule. `agental.policy().hidden_variables` says whether variables can be kept out where you are: where they cannot, setting one with hosts raises an `ApiError` with the code `hidden_variables_unavailable`.
329
+
330
+ ## Decide what it may reach
331
+
332
+ 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:
333
+
334
+ ```python
335
+ # Nothing gets out of a sandbox on this template...
336
+ template = agental.templates.create("locked-down")
337
+ template.set_network("blocked")
338
+
339
+ # ...but this one may reach its own database, from its first boot.
340
+ sandbox = agental.sandboxes.create(template=template, allow=["203.0.113.7"])
341
+
342
+ # Its own addresses, from now on. The list replaces the one it had, and [] takes them away.
343
+ sandbox.set_network(allow=["203.0.113.7", "198.51.100.0/24"])
344
+
345
+ network = sandbox.network()
346
+ print(network.outbound, network.allow) # what it is held to: allowlist [...]
347
+ print(network.template, network.own) # the template's rule as it has it, and its own addresses
348
+ ```
349
+
350
+ The rule is `"open"`, `"blocked"`, or `"allowlist"` with the addresses in `allow`, and `set_network` 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.
351
+
352
+ 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 `set_network` answers. Nothing is restarted, and your commands, files and ports still reach it. `agental.policy().network_policy` 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 raises an `ApiError` with the code `network_policy_unavailable`.
353
+
354
+ ## See what it uses
355
+
356
+ 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:
357
+
358
+ ```python
359
+ from datetime import datetime, timedelta, timezone
360
+
361
+ # The last hour of one sandbox, oldest first.
362
+ metrics = sandbox.metrics()
363
+ if metrics.points:
364
+ last = metrics.points[-1]
365
+ print(last.cpu_utilization, last.memory_used_bytes) # 0.42 is 42% of its CPUs
366
+
367
+ # Another stretch, with a point every five minutes.
368
+ sandbox.metrics(since=datetime.now(timezone.utc) - timedelta(days=1), step_seconds=300)
369
+
370
+ # Every sandbox you have, at one moment. Without at, now.
371
+ for each in agental.metrics(at="2026-10-14T09:35:00Z").sandboxes:
372
+ print(each.name, each.reading.memory_used_bytes if each.reading else None)
373
+
374
+ # How many of them were running, step by step, over the last 24 hours.
375
+ running = agental.running_over_time()
376
+ print(running.points[-1].running)
377
+ ```
378
+
379
+ A sandbox has no point for a moment it was stopped at, and its `reading` is then `None`. A figure its server did not have is `None` too. `complete` is false when a server did not answer, and readings may then be missing. A `datetime` must say its time zone. `agental.policy().metrics` says whether sandboxes are read where you are: where they are not, both `metrics` calls raise an `ApiError` with the code `metrics_unavailable`. `running_over_time` answers either way.
380
+
381
+ ## What it costs
382
+
383
+ ```python
384
+ # Where the account stands, in millionths of a US dollar.
385
+ billing = agental.billing()
386
+ print(billing.used_micros, billing.top_up_micros)
387
+
388
+ # What is sold: the monthly plans in tiers, the hourly price of each size in plans, top-ups and their bonus, and the free month.
389
+ prices = agental.prices()
390
+ for plan in prices.tiers:
391
+ print(plan.id, plan.dollars_per_month, plan.sizes)
392
+
393
+ # Start paying $500 for $537 of credits. A person pays at url: nothing is charged before.
394
+ url = agental.checkout(top_up=500).url
395
+
396
+ # When the credits run out, charge the saved card $50 for a top-up, before anything is stopped.
397
+ agental.set_auto_reload(50)
398
+ ```
399
+
400
+ `used_micros` 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.top_ups` lists what each offered amount adds, and `prices.top_up_bonus` the curve. `checkout(plan="plan100")` starts a plan the same way, or moves an account that has one at once, with `url` None and `changed` True. A checkout whose answer is lost is not sent again.
401
+
402
+ `set_auto_reload(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. `set_auto_reload(None)` turns it off, and `billing().auto_reload` says how it is set. It needs a plan or a top-up bought first, which saves the card: `billing().can_auto_reload` says whether it can be set, and the call answers `409 no_card` until then.
403
+
404
+ The $20 plan makes tiny and small sandboxes, the $100 plan medium ones too, and the $500 and $2000 plans every size: each plan's `sizes` in `prices().tiers` says which. `prices().plans`, despite its name, is the sizes and what an hour of each costs. A create, fork or template start of a size the plan does not make raises 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.
405
+
406
+ ## How many you run, and how fast you start them
407
+
408
+ 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. A new account's free month, on the $20 plan, runs and starts at most 4 small sandboxes at once, until the plan is charged. `prices().tiers` gives each plan's `running_units` and `free_burst_units`, `prices().free_month.running_units` the free month's, and `prices().burst_fee` 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.
409
+
410
+ ```python
411
+ burst = agental.burst()
412
+ print(burst.running_units, burst.free_burst_units) # 100 100
413
+ print(burst.free_now_mb / 4096) # small sandboxes you may start now for nothing
414
+ print(burst.spent_micros, burst.budget_micros) # burst fees this month, and the most you spend on them
415
+
416
+ # What starting 30 medium ones now would cost, and in how many seconds it would cost nothing.
417
+ quote = agental.burst(size="medium", count=30).quote
418
+ print(quote.fee_micros, quote.free_in_seconds)
419
+
420
+ # Spend at most $10 a month on burst fees; 0 refuses every start that would cost one, None puts back the default ($50).
421
+ agental.set_burst_budget(10_000_000)
422
+ ```
423
+
424
+ A create, fork or start takes `max_burst_fee_micros`, the most it may pay, and the sandbox's `burst_fee_micros` says what it paid:
425
+
426
+ ```python
427
+ sandbox = agental.sandboxes.create(max_burst_fee_micros=50_000) # at most 5 cents
428
+ print(sandbox.burst_fee_micros) # 0 within the free burst
429
+ sandbox.start(max_burst_fee_micros=0, max_burst_wait=300) # free, waiting up to 5 minutes for it to be
430
+ ```
431
+
432
+ Where `enforced` is True, a start is refused with an `ApiError` of status 429 and one of these codes, all in `BURST_REFUSALS` (`from agental import BURST_REFUSALS`). `fee_micros` says what it would have cost and `retry_after_seconds` when it would cost nothing, or None when waiting would not help:
433
+
434
+ - `running_limit_reached`: the account already runs its plan's running count. Stop something first.
435
+ - `burst_fee_too_high`: the start would cost more than `max_burst_fee_micros`.
436
+ - `burst_budget_reached`: the fee would take this month's burst fees past the budget.
437
+ - `burst_in_free_month`: the start would cost a fee, which the free month does not pay.
438
+
439
+ With `max_burst_wait`, in seconds, a start refused with one of the last three waits `retry_after_seconds`, 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().burst_micros` is this month's.
440
+
441
+ ## Send it to your own collector
442
+
443
+ Your sandboxes' metrics and lifecycle events can be sent to an OTLP/HTTP collector of your own:
444
+
445
+ ```python
446
+ # Set it, or replace the one you had. Nothing is sent yet.
447
+ agental.set_telemetry("https://otlp.example.com", headers={"x-api-key": "..."})
448
+
449
+ # Send it one empty export, and see what it answered.
450
+ print(agental.test_telemetry()) # TelemetryTest(ok=True, status=200, message=None)
451
+
452
+ # Where it is, the names of its headers, and how the last export went. None when none is set.
453
+ telemetry = agental.get_telemetry()
454
+ if telemetry:
455
+ print(telemetry.headers, telemetry.last_export_at, telemetry.last_error)
456
+
457
+ # Stop sending to it.
458
+ agental.delete_telemetry()
459
+ ```
460
+
461
+ `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. `delete_telemetry` and `test_telemetry` raise an `ApiError` with the status 404 when none is set. `agental.policy().telemetry` says whether anything is sent to a collector where you are: where it is not, these calls raise an `ApiError` with the code `telemetry_unavailable`. Metrics are sent only where `metrics` is true as well.
462
+
463
+ ## What agental keeps about you
464
+
465
+ ```python
466
+ import json
467
+
468
+ exported = agental.export_account()
469
+ print(exported["account"]["email"], len(exported["sessions"]))
470
+ with open("agental-account.json", "w") as file:
471
+ json.dump(exported, file, indent=2)
472
+ ```
473
+
474
+ 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.
475
+
476
+ ## How long its history is kept
477
+
478
+ The output of your sandboxes' commands, their agent conversations and their events are kept 30 days unless you choose otherwise.
479
+
480
+ ```python
481
+ retention = agental.get_data_retention()
482
+ print(retention.keep_days, retention.choices) # 30 [1, 7, 30, 90]
483
+ for each in retention.retained:
484
+ print(each.what, each.kept) # what agental keeps for periods of its own
485
+
486
+ agental.set_data_retention(7) # or 1, 30, 90, or None to keep it until the sandbox is deleted
487
+ ```
488
+
489
+ 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, its events a day later. `last_cleared` says what the last clean-up deleted.
490
+
491
+ ## Email
492
+
493
+ 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.
494
+
495
+ ```python
496
+ state = sandbox.email.enable()
497
+ print(f"support@{state.domain}") # support@sbx-3f9a1c2b7d4e5a60.k7m2x9qd.agental.email
498
+ ```
499
+
500
+ 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.
501
+
502
+ ```python
503
+ page = sandbox.email.list(limit=1) # newest first
504
+ if page.messages:
505
+ message = sandbox.email.get(page.messages[0].id)
506
+ print(message.from_, message.subject, message.text)
507
+ ```
508
+
509
+ `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.
510
+
511
+ A sandbox can write to one address only: the sign-in address of its own account. So a send names nobody.
512
+
513
+ ```python
514
+ sent = sandbox.email.send("The build passed", "All 812 tests passed.")
515
+ print(sent.to, sent.state)
516
+ ```
517
+
518
+ - The message is plain text. `from_` is the part before the `@`, `sandbox` unless you say, and `from_name` the name beside it. `attachments` are `SendEmailAttachment(filename, content_base64, content_type)`. `reply_to` 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.
519
+ - `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.
520
+ - 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 `idempotency_key` yourself to be able to ask again after your process has died.
521
+ - 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.
522
+ - `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`.
523
+
524
+ 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:
525
+
526
+ ```python
527
+ key = agental.api_keys.create("mail-agent", actions=["email.read", "email.send"], sandbox_ids=["sbx_3f9a1c2b7d4e5a60"])
528
+ mail = Agental(api_key=key.secret).sandboxes.email("sbx_3f9a1c2b7d4e5a60")
529
+ messages = mail.list().messages
530
+ ```
531
+
532
+ ## What the SDK does for you
533
+
534
+ - 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 `idempotency_key` yourself to be able to ask again after your process has died.
535
+ - 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.
536
+ - A command is never sent twice. One that was cut off may have done part of its work, so that is yours to decide.
537
+ - A delete carries the confirm header the API asks for.
538
+
539
+ ## Errors
540
+
541
+ ```python
542
+ from agental import ApiError
543
+
544
+ try:
545
+ sandbox.exec("make")
546
+ except ApiError as error:
547
+ if error.code == "sandbox_not_running":
548
+ sandbox.start()
549
+ else:
550
+ raise
551
+ ```
552
+
553
+ - `ApiError` is the API's own refusal, with `status`, `code` and `message`. Decide from `code`. A start refused for its burst also has `fee_micros` and `retry_after_seconds`: see [How many you run](#how-many-you-run-and-how-fast-you-start-them).
554
+ - `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.
555
+ - `AgentalConnectionError` is the API not being reached. `AgentalTimeoutError` is a wait that ran out.
556
+
557
+ All of them are `AgentalError`.
558
+
559
+ ## Webhooks
560
+
561
+ ```python
562
+ hook = agental.create_webhook("https://example.com/hooks/agental", events=["sandbox.stopped", "sandbox.error"])
563
+ hook.signing_secret # given this once
564
+ agental.list_webhooks() # each with its tries of the last 30 days
565
+ agental.get_webhook(hook.id)
566
+ agental.update_webhook(hook.id, events=["*"]) # every event, and any added later
567
+ agental.rotate_webhook_secret(hook.id) # a new signing_secret; the old one stops working
568
+ agental.delete_webhook(hook.id)
569
+ ```
570
+
571
+ `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`.
572
+
573
+ Check each post your server receives:
574
+
575
+ ```python
576
+ from agental import verify_webhook
577
+
578
+ genuine = verify_webhook(raw_body, request.headers.get("x-agental-signature"), signing_secret)
579
+ ```
580
+
581
+ Pass the body exactly as it arrived, before any JSON parsing.
582
+
583
+ ## API keys
584
+
585
+ 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.
586
+
587
+ ```python
588
+ from datetime import datetime, timedelta, timezone
589
+
590
+ key = agental.api_keys.create(
591
+ "ci-github",
592
+ # Use sandboxes, make, fork and resume them, and delete the ones it made.
593
+ preset="ci",
594
+ # These templates and the sandboxes on them.
595
+ template_ids=["tpl_coding_agent"],
596
+ # Refused from anywhere else.
597
+ allow_from=["203.0.113.0/24", "2001:db8::/48"],
598
+ # Optional: when it stops working.
599
+ expires_at=datetime.now(timezone.utc) + timedelta(days=30),
600
+ )
601
+ key.secret # given this once
602
+
603
+ agental.api_keys.list() # never with their secrets
604
+ agental.api_keys.get(key.id) # with when and from where it was last used
605
+ agental.api_keys.set_allow_from(key.id, []) # anywhere: the one thing that changes after it is made
606
+ agental.api_keys.usage(key.id) # what the sandboxes it made ran, at list price
607
+ agental.api_keys.delete(key.id)
608
+ ```
609
+
610
+ - The presets are `read-only`, `full-sandbox`, `ci` and `admin`. Give `preset` or `actions`, one of them: anything else raises a `ValueError` before it is sent. `actions` takes names such as `sandbox.read`, `exec` and `email.send` (the type `ApiKeyAction` lists them all), or `"*"` for everything.
611
+ - `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.
612
+ - 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`.
613
+ - 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`.
614
+ - 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.
615
+
616
+ `allow_from` 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 `sandbox_ids` instead. On a network with IPv6 the API sees your IPv6 address, so a list of IPv4 addresses alone refuses you there.
617
+
618
+ ## Options
619
+
620
+ ```python
621
+ Agental(
622
+ api_key="...", # default: AGENTAL_API_KEY, then the agental command's sign-in
623
+ base_url="https://agental.io", # default: AGENTAL_API_URL, then this
624
+ max_retries=3, # how many times a request that is safe to send again is sent again
625
+ retry_delay=1.0, # seconds between those tries, at the least
626
+ poll_interval=2.0, # seconds between looks at what is waited for: a start, a command
627
+ http_client=my_httpx_client, # an httpx.Client, or httpx.AsyncClient for AsyncAgental; you close it
628
+ )
629
+ ```
630
+
631
+ With no key given and none in `AGENTAL_API_KEY`, the client uses the key the agental command saved when you signed in on this computer with `npx agental@latest`, in `~/.config/agental/credentials.json` (or under `XDG_CONFIG_HOME`), for the API it signed in to. Every request says it comes from `agental-python/` and the SDK's version, which is `agental.__version__`.
632
+
633
+ The full API is OpenAPI 3.1 at `https://agental.io/openapi.json`.