Containers API
Containers run a Docker image you choose as a background job, for as long as the work takes. This is where the work goes that a function cannot do: a video transcode, a large import, a PDF pipeline, an ML batch — anything needing minutes of CPU or a real toolchain rather than a second or two of JavaScript.
A job is started, not awaited
POST /v1/container/{instance} returns as soon as the machine exists. Nothing is streamed back and there is no response body to wait on.
const job = await fetch(`${API}/v1/container/jobs`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({
image: "ghcr.io/me/transcode:v4",
cmd: ["/bin/transcode", "--input", "blob:9f3c…:7a1b…"],
env: { QUALITY: "high" },
size: "medium",
timeout_ms: 600000,
}),
}).then((r) => r.json());
// job.job.id — keep it. That is how you find out what happened.You find out how it ended one of two ways: poll GET /v1/container/{instance}/{id}, or have the instance call a function when the job finishes. The callback carries the exit code and what the job cost, and is sent only after the result has been recorded, so a callback that fails cannot lose the result or re-run the job. A failed callback is retried on its own.
The job itself writes whatever it produces to blob, your datastore, or anywhere else it can reach. Nothing is captured for you.
The launch body
| Field | Rules |
|---|---|
image | Required. An image reference such as ghcr.io/you/worker:v2, at most 512 characters, that the instance's allowed images match. Images from localhost, an IP address, the platform's own registry or a private-network name are refused on every instance. |
size | small (default), medium or large. |
cmd | An array of strings: at most 64, each at most 4,096 characters. Omit it to run the image's own command. |
env | An object of string values: at most 32 variables, names matching [A-Za-z_][A-Za-z0-9_]*, names and values together under 8,192 bytes. Reserved prefixes are refused. |
timeout_ms | An integer, at least 1000 and at most the instance's maximum job time. Defaults to that maximum. |
Anything the caller needs to hand a job that does not fit in those caps belongs in a blob file whose key goes in cmd or env.
The reply, and every read of a job, is {job}:
{ "job": { "id": "j3f9c…", "status": "running", "image": "ghcr.io/me/transcode:v4", "size": "medium",
"exit_code": null, "timeout_ms": 600000, "error": null,
"started_at": 1791561600000, "ended_at": null, "cost_usd": 0 } }| Status | When |
|---|---|
400 INVALID_ARGUMENT | A field breaks a rule above, or the worst case for this size and timeout is over the instance's cost ceiling — the message names a timeout that would fit. |
402 PAYMENT_REQUIRED | The organization has no payment method and the job would pass its monthly limit. |
403 FORBIDDEN | The image is not in the allowed list, or the list is empty. |
429 RESOURCE_EXHAUSTED | The instance already has its maximum jobs running. |
429 RATE_LIMITED | More launches than the instance's rate limit: 120 a minute by default. |
Job status
status | Means |
|---|---|
running | The machine exists. The only status that changes. |
done | The command exited 0. A machine that ended without recording an exit code is also done, with exit_code: null and error saying so. |
failed | A non-zero exit (exit_code holds it), or error says why: timed out, or a failure to start or to watch the machine. |
canceled | Canceled through the API or the console (error: "canceled"), or the instance was deleted while it ran ("instance deleted"). |
The completion callback
With Call on completion set, the named function receives a POST with x-ae-trigger: container and this body:
{ "job_id": "j3f9c…", "instance": "6b0e…", "status": "done", "exit_code": 0,
"image": "ghcr.io/me/transcode:v4", "size": "medium",
"started_at": 1791561600000, "ended_at": 1791561912000, "cost_usd": 0.0123, "error": null }instanceis the container instance's id, not its name.- The body is not signed. Check
x-ae-triggerinstead: the platform strips that header from every inbound request, so a request to the function's public URL cannot claim to be a callback. - Any 2xx is delivered. Anything else, or no answer within 30 seconds, is retried about once a minute, five attempts in all. An attempt that timed out may still have run, so a callback can arrive more than once — key what it does on
job_id. - A job ended by deleting its instance sends no callback.
| Endpoint | What it does |
|---|---|
POST /v1/container/{instance} | Start a job. Returns immediately with its id. |
GET /v1/container/{instance} | List jobs, newest first: {jobs, cursor}. Filter with ?status=running; limit is 25 by default, at most 100. For the next page pass the reply's cursor as ?before=; it is null on the last page. |
GET /v1/container/{instance}/sizes | What this instance will accept: sizes, allowed images, limits. |
GET /v1/container/{instance}/{id} | One job: status, exit code, how long it ran, what it cost. |
GET /v1/container/{instance}/{id}/logs | What the job printed, oldest first, with a cursor for more. |
POST /v1/container/{instance}/{id}/cancel | Stop a running job and destroy its machine now. Returns {job, canceled}; canceled is false when the job had already ended. |
Logs, and how long things are kept
A job's output is available while it runs and for about a week afterwards. Open a run in the console, or call the logs endpoint. If logs cannot be retrieved the response is empty with a note rather than an error — a job's record (exit code, timing, cost) is kept separately and never depends on them.
The job list is paginated newest-first. Finished jobs are kept for 30 days and then removed. A running job is never removed on age.
Only your server can start a job
Containers take an organization API key and nothing else. An end-user identity token is rejected.
If a page needs to start a job, have it call a function, which can decide whether this particular user should be starting this particular job.
A function does not need a key to do that. Grant it container and it gets env.container, the same way it gets env.datastore:
export default {
async fetch(request, env) {
const token = (request.headers.get("authorization") || "").replace("Bearer ", "");
const user = await env.auth.verifyToken({ instance: "users" }, token);
if (!user?.claims?.canExport) return new Response("no", { status: 403 });
const job = await env.container.run({ instance: "jobs" }, {
image: "ghcr.io/me/export:v3",
cmd: ["/bin/export", "--user", user.uid],
timeout_ms: 600000,
});
return Response.json({ job_id: job.id });
},
};The grant is the bound: a function with no container grant has no env.container at all, and one granted container:jobs cannot reach any other instance. Every limit on this page still applies — the image allowlist, the timeout, the concurrency cap and the cost ceiling are checked before the machine starts, whoever asked for it.
Sizes
| Size | Machine | Billed rate |
|---|---|---|
small | 1 CPU, 512 MB | ×1 |
medium | 2 CPU, 2 GB | ×4 |
large | 4 CPU, 8 GB | ×12 |
What a job cannot do
A job has no inbound network: nothing can connect to it, and it has no address to be reached at. It has no disk that survives it — write anything you want to keep to blob or datastore before it exits. It runs once and disappears; a crash is not restarted.
What it is given in its environment without asking is AE_JOB_ID, its own id, and — when the instance names a blob store — AE_BLOB_URL and AE_BLOB_TOKEN. Names beginning AE_ and FLY_ are reserved and rejected rather than ignored.
Reaching blob without a key in the job's env
Name a blob instance as the container instance's blob store, and every job launched there starts with two more variables:
AE_BLOB_URL https://api.altengine.net/v1/blob/{store}
AE_BLOB_TOKEN a bearer for that store, and nothing else# in the job: ask for a link, then PUT the bytes straight to storage
curl -sX POST "$AE_BLOB_URL/uploads" \
-H "authorization: Bearer $AE_BLOB_TOKEN" -H "content-type: application/json" \
-d '{"name":"out.csv","size":'$(stat -c%s out.csv)',"content_type":"text/csv"}'From there it is the blob API as any other client uses it, so the bytes go to storage directly and never through altengine. The token puts and gets — it cannot delete, cannot publish, and cannot list the store. It expires with the job's own time limit, and it is minted by the platform, so it is not something you can issue, widen or point at a different store. Leave the setting unset and a job gets no blob credential at all.
Naming a store here delegates it. Anything that can launch a job on this instance can write to that store for the life of the job — including the image itself, whoever wrote it. That is the same shape as call on completion, which lets a launch invoke a function holding grants the launcher does not have.
Limits, and what each one is for
Every one of these is set on the instance in the console, and each bounds a different way a job can cost more than you meant.
| Setting | Default | What it bounds |
|---|---|---|
| Allowed images | empty | Which images may run, at most 50 entries. An exact tag matches only that tag; repo:* allows any tag of one repository; * allows any image. An empty list runs nothing. |
| Maximum job time | 5 minutes | The longest any job may run, from 1 second up to an hour. A job reaching it is killed and billed for the time it ran. Jobs may ask for less, never more. |
| Jobs at once | 2 | 1 to 50. The real spend bound — the timeout caps one job, this caps the bill. A launch past the limit is refused, not queued. |
| Cost ceiling per job | $1.00 | $0.01 to $100. Checked before the machine starts, against the worst case for the size and timeout asked for. A job that could exceed it is refused up front, and the refusal says what would fit. |
| Call on completion | none | A function to invoke when a job ends, as {functions-instance}/{function} — see the callback. |
| Blob store | none | Which blob instance a job may reach without a key of its own — see above. |
| Rate limit | 120 a minute | Launches per minute. A value you set can only lower it. If the limit cannot be checked, a launch is refused rather than let through. |
A new instance runs nothing until you say what it may run. The allowed-images list starts empty.
What it costs
Two things: the time a machine ran, scaled by its size, and a small fee per job that covers starting and watching it. A second on a large counts as twelve on a small, so one rate covers every size — see pricing for the numbers.
A job is billed only for the time its machine actually existed, and never for longer than the timeout it was started with. Nothing accrues while an instance sits idle.
Without a payment method, an organization's jobs are limited to $3 a month. Each launch reserves the most the job could cost (its size and timeout), and what it does not use is released when it ends. A launch that would pass $3 is refused with 402 PAYMENT_REQUIRED. With a payment method on file there is no such limit.
Every job pays for its own start-up. Pulling the image, booting the machine and tearing it down afterwards take roughly ten seconds even when your command finishes instantly, and that time is charged for.
So containers are for work measured in minutes, where ten seconds is a rounding error. For work measured in milliseconds, use a function: it starts in single-digit milliseconds and is billed by CPU time rather than wall-clock.
From an AI agent
The MCP server can start a job, list them, check one, and cancel one. It can also change an instance's settings — the limits above included — with patch_instance_config, which takes a credential holding instances control scope, and full data access to that instance for the fields that decide where a job's output goes. Give an agent a narrower credential if you want the allowed-images list to be yours alone.