← Back to API docs

Docs

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.

Closed beta. Containers are enabled per organization. If the Containers tab isn't in your console, ask us to switch it on.

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 — a job that runs for twenty minutes has nowhere to send twenty minutes of output.

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 is the better shape for anything real — it fires once, 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.

Containers endpoints
EndpointWhat it does
POST /v1/container/{instance}Start a job. Returns immediately with its id.
GET /v1/container/{instance}List jobs, newest first. Filter with ?status=running.
GET /v1/container/{instance}/sizesWhat 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}/logsWhat the job printed, oldest first, with a cursor for more.
POST /v1/container/{instance}/{id}/cancelStop a running job and destroy its machine now.

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, which outlives the logs, so a row never survives as the last trace of something whose output is already gone. 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, which is the one place altengine treats identity tokens differently from every other service.

The reason is that access rules bound what a user can read and write, and there is no equivalent for what a user can spend. An identity token lives in a browser, where whoever is sitting at it can replay it. 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

Container machine sizes
SizeMachineBilled rate
small1 CPU, 512 MB×1
medium2 CPU, 2 GB×4
large4 CPU, 8 GB×12

A fixed set rather than free-form CPU and memory, so the cost of a job is knowable before it starts — which is what makes the per-job ceiling below possible.

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 storeAE_BLOB_URL and AE_BLOB_TOKEN. Names beginning AE_ are reserved and rejected rather than ignored, so a variable you set is either used or refused — never silently dropped.

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 (the one write whose effect outlives the job that made it), 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.

Container instance settings
SettingDefaultWhat it bounds
Allowed imagesemptyWhich images may run. An exact tag matches only that tag; repo:* allows any tag of one repository. An empty list runs nothing.
Maximum job time5 minutesThe longest any job may run, 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 once2The 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.00Checked 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 completionnoneA function to invoke when a job ends, as {functions-instance}/{function}.
Blob storenoneWhich blob instance a job may reach without a key of its own — see above.

A new instance runs nothing until you say what it may run. The allowed-images list starts empty on purpose. The alternative — empty meaning "anything" — would make the safe-looking default the dangerous one, on the single feature whose description is run code someone else wrote.

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, because there is no machine when there is no job.

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 — that time is real, the machine exists for it, and it 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 deliberately cannot change an instance's settings: every limit above lives there, so a tool that could edit them would undo the rest of this page. An agent that needs a new image allowed asks you to allow it.