---
slug: agents/task-contract
title: The task contract
kind: reference
surface: agents
summary: How code runs on Aetherfy as a run-once task — no framework or handler signature, exit codes that decide success or failure, stdout and stderr as logs, pulling the input payload over HTTP, at-most-once execution, and the 60-minute backstop.
sources:
  - aetherfy-control-plane:shared/job_runs.py
  - aetherfy-control-plane:shared/fly_exit_info.py
  - aetherfy-control-plane:api/routes/deployments.py
  - aetherfy-control-plane:models/deployment.py
---

# The task contract

## How Aetherfy runs your code

A task on Aetherfy runs as a plain script. There is no framework to import, no
handler function to export, and no server to start. Aetherfy starts a machine,
executes your entrypoint file, and reads the exit code when the process ends.

Your entrypoint is executed top to bottom, exactly as if you had run it
yourself. A Python `if __name__ == "__main__":` block fires normally, and a
Node file's top-level statements run normally.

This applies to `type: job` agents — the run-once type. Long-lived `service`
agents on Aetherfy serve HTTP instead and are not covered by this page.

## Exit codes and how Aetherfy records the outcome

Aetherfy decides whether a run succeeded from the process exit code alone.

| Exit code | Run recorded as |
|---|---|
| `0`, or an unknown or unparseable code | `completed` |
| Any known non-zero code | `failed` — the reason is kept with the run |

An uncaught exception exits non-zero on every runtime Aetherfy supports, so a
crash fails the run without you writing any error handling. You do not need to
call `sys.exit(1)` or `process.exit(1)` yourself to mark a failure, though doing
so is fine.

Some failures carry extra detail on the run record:

| Failure | How Aetherfy records it |
|---|---|
| Out of memory | A failure, annotated `(oom_killed)` in the run's error message |
| Host or infrastructure failure | "run machine entered Fly state 'failed' (host/infra failure)" |

Signal deaths map to the conventional 128+N codes:

| Signal | Exit code |
|---|---|
| SIGKILL, including an out-of-memory kill | 137 |
| SIGTERM | 143 |

## Shutdown signals on Aetherfy tasks

When Aetherfy needs to stop a task, it forwards SIGTERM to your process and
then allows a **10-second** grace period before SIGKILL. Use that window to
flush a buffer or close a connection; do not use it for real work.

The longer 120-second shutdown contract you may see referenced elsewhere applies
to `service` agents on Aetherfy, not to tasks. For a task, plan on 10 seconds.

## Logging from an Aetherfy task

Everything your program writes to **stdout** and **stderr** becomes the run's
logs. Plain `print` in Python and `console.log` in Node are the supported
logging interface on Aetherfy — there is no logging SDK to install and no
special sink to configure.

```python
print("starting nightly rollup", flush=True)
```

```javascript
console.log('starting nightly rollup');
```

Aetherfy tags each record with the stream it came from (`stdout`, `stderr`, or
`system`), so you can filter by stream when reading logs back. Retrieval flags,
retention, and the per-line caps are on
[/agents/runs-and-logs](/agents/runs-and-logs).

## Reading the input payload from Aetherfy

Input on Aetherfy is **pulled, not pushed**. Your task is not handed an argument
or a request body; it fetches its payload over HTTP using credentials Aetherfy
puts in the environment.

Every run receives these three variables:

| Variable | Contains |
|---|---|
| `AETHERFY_API_URL` | Base URL of the Aetherfy API |
| `AETHERFY_SPAWN_ID` | This run's id |
| `AETHERFY_API_KEY` | A bearer token scoped to this agent |

Fetch the payload with:

```text
GET {AETHERFY_API_URL}/deployments/{AETHERFY_SPAWN_ID}/payload
Authorization: Bearer {AETHERFY_API_KEY}
```

The response shape is:

```json
{"payload": {}}
```

**The empty case is the normal case.** For a scheduled fire, or a manual run
started without input, `payload` is an empty object and the status is **200**,
not an error. Write the task so that no input is the path it takes most often,
and treat any input as an optional override.

A run started by a parent agent carries its payload through the same endpoint,
so one implementation covers scheduled fires, manual runs, and spawned runs.

### Complete Python example

Standard library only — no dependencies to install:

```python
"""An Aetherfy task: pulls its input payload, then does its work."""

import json
import os
import urllib.request


def fetch_payload():
    """Return this run's input payload, or {} when the run was given none."""
    api_url = os.environ["AETHERFY_API_URL"]
    spawn_id = os.environ["AETHERFY_SPAWN_ID"]
    api_key = os.environ["AETHERFY_API_KEY"]

    request = urllib.request.Request(
        f"{api_url}/deployments/{spawn_id}/payload",
        headers={
            "Authorization": f"Bearer {api_key}",
            # Always set an explicit User-Agent. Python's urllib otherwise sends
            # "Python-urllib/3.x", a signature many CDNs' bot protection blocks
            # outright — producing a 403 that reads like an auth failure.
            "User-Agent": "nightly-report/1.0",
        },
    )
    with urllib.request.urlopen(request, timeout=30) as response:
        body = json.load(response)

    return body.get("payload") or {}


def main():
    payload = fetch_payload()

    # A scheduled fire sends no input, so this is the normal path.
    target_date = payload.get("date")
    if target_date is None:
        print("no input payload: processing the default window", flush=True)
    else:
        print(f"input payload requested date {target_date}", flush=True)

    print("done", flush=True)


if __name__ == "__main__":
    main()
```

### Complete Node example

Uses the built-in `fetch`, available on the `node20` and `node22` runtimes:

```javascript
/** An Aetherfy task: pulls its input payload, then does its work. */

async function fetchPayload() {
  const apiUrl = process.env.AETHERFY_API_URL;
  const spawnId = process.env.AETHERFY_SPAWN_ID;
  const apiKey = process.env.AETHERFY_API_KEY;

  const response = await fetch(`${apiUrl}/deployments/${spawnId}/payload`, {
    headers: {
      Authorization: `Bearer ${apiKey}`,
      'User-Agent': 'nightly-report/1.0',
    },
  });

  if (!response.ok) {
    throw new Error(`payload fetch failed with status ${response.status}`);
  }

  const body = await response.json();
  return body.payload ?? {};
}

async function main() {
  const payload = await fetchPayload();

  // A scheduled fire sends no input, so this is the normal path.
  const targetDate = payload.date;
  if (targetDate === undefined) {
    console.log('no input payload: processing the default window');
  } else {
    console.log(`input payload requested date ${targetDate}`);
  }

  console.log('done');
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});
```

## Environment variables Aetherfy provides

Beyond the three payload variables above, every Aetherfy agent machine carries:

| Variable | Contains |
|---|---|
| `AETHERFY_AGENT_ID` | The agent's id |
| `AETHERFY_AGENT_NAME` | The agent's name |
| `AETHERFY_REGION` | The region this machine is running in |
| `AETHERFY_WORKSPACE` | The agent's workspace, if it has one |
| `AETHERFY_VECTORS_URL` | Base URL for the Aetherfy vector database |
| `AETHERFY_SPAWN_URL` | Endpoint for spawning worker runs |
| `AETHERFY_DEPLOYMENT_ID` | The deployment this machine is running |

The entire `AETHERFY_` prefix is reserved on Aetherfy — you cannot set a secret
that would shadow one of these. See [/agents/secrets](/agents/secrets).

## At-most-once execution on Aetherfy

A scheduled occurrence on Aetherfy fires **at most once**. A failed run is
**not** retried; the next attempt is simply the next scheduled occurrence.
Aetherfy has no automatic retry, no backoff, and no dead-letter queue for runs.

Two consequences are worth designing around explicitly.

| Consequence | What to do about it |
|---|---|
| A run can execute more than the zero times you assume, and its effects persist | Make the work idempotent — upsert rather than insert, and key writes by the period they cover so a re-run overwrites rather than duplicates |
| An occurrence can fail to happen at all | Do not assume every occurrence ran. Derive the window you process from your own data watermark ("everything since the last row I wrote") rather than from the clock ("the last 24 hours") |

Written that way, a task that misses a night catches up automatically on the
next fire, because the window it computes is wider. Written the other way, the
missing night stays missing forever.

If you need every single occurrence processed with a guarantee, a schedule is
the wrong tool on Aetherfy — put the work in a durable queue and let a task
drain it.

## The 60-minute backstop on Aetherfy

A run still executing after **60 minutes** is terminated by Aetherfy and
recorded as failed. The time it ran is billed.

This is a cost-safety backstop, not a target, and it is **not configurable**. It
exists so a task stuck in a loop cannot bill indefinitely.

If your work legitimately exceeds an hour, split it into smaller scheduled
batches — for example, process one hour of data per fire on an hourly schedule
instead of a day of data per fire on a daily one. Each fire then finishes well
inside the backstop, and the watermark pattern above makes the batches
self-correcting.
