<!-- © TALOS Group Pty Ltd, used under licence by TALOS Defence Pty Ltd. -->
<!-- Body written by slice D-14, 23 October 2026. -->
<!-- depth: 3 -->

# The Hangar SDK

The Hangar SDK (software development kit) is a Python package, `gethangar`, that lets your product run your application on Hangar for each of your customers. You pay from your prepaid credit for the jobs and sessions that your server starts, and you set a spend limit on each request key.

Your server stores request keys and keeps each customer as a tenant. A request key lets your server start jobs for your organisation, or for one tenant only. You choose which when you make the key. With the package, your server can do these tasks for a tenant:

- Start jobs of your releases and CPU sessions.
- Follow each job.
- Download the artefacts and the return of the job. The return is the signed record that ends a job.
- Verify the return.

The package has one supported API (the set of calls that your code can use): sessions, execute, inspect, retrieve and verify. The package uses only the Python standard library. The `hangar` command line is a second client of the same API routes. The Hangar app shows the same jobs, releases, tenants and keys.

## 1. Install

```sh
pip install gethangar
```

The package is `gethangar` on PyPI, and you import `hangar_sdk`. The package runs on CPython 3.11 and later. The package imports nothing outside the Python standard library, at runtime or at build time. The package connects to Hangar over TLS 1.3. The package checks the connection against the certificate authorities of your system, or against the file that `HANGAR_CA_FILE` names.

TALOS Defence builds each release of the package twice and compares the two builds. The `sdk-release-v1` manifest lists the digest of the Python wheel (the installable file) and the digest of the source archive. TALOS Defence signs the manifest with its release root.

```python
import hangar_sdk
print(hangar_sdk.__version__)   # 2.1.0
```

## 2. Request keys

Your server signs its requests with a request key. An owner of your organisation makes the key in the Hangar app or with the CLI. The owner signs in with a passkey. Every organisation whose sign-up checks have passed can make organisation keys with `jobs`, `sessions`, `uploads` and `usage`. A key with `jobs` can also start, follow and stop GPU jobs now for your organisation (see A GPU now, below). A key never books, tops up, approves or makes a key. A tenant key, and the `tenants` and `releases` capabilities, need a vendor organisation. For any other organisation, the answer is `vendor_not_enabled`. An organisation whose sign-up is held or declined gets `account_not_active`.

```
$ hangar keys create --name build-server --capability jobs --capability uploads
Key hgk_syd_7c41e0b3d9f2a81c5e604b2d made for TALOS Defence Pty Ltd ✓
  capabilities  jobs, uploads
  scope         the organisation
  ends          Thu 21 Jan 2027
Secret 4c1f0a9e7b2d5c83e6f190a4d7b2c58e19f3a6d0b4e72c158a9d3f6e0b1c47a2
Hangar shows the secret this once. Keep it in your secret store.
```

A key has these parts:

| Part | What it is |
|---|---|
| Key id | `hgk_`, the cell that issued the key (`syd` for Sydney, `mel` for Melbourne), `_` and 24 hexadecimal digits. The key id is not secret. The SDK and the CLI send each request to the API of the cell the key names. |
| Secret | 32 random bytes, shown once as 64 hexadecimal digits. Hangar stores the secret sealed under the key of your organisation. |
| Capabilities | One or more of `jobs`, `sessions`, `uploads`, `tenants`, `releases` and `usage`. Hangar refuses any other call with `key_capability_missing`. |
| Scope | Your organisation, or one tenant. A tenant key works for that tenant only, and has only `jobs`, `sessions`, `uploads` and `usage`. Packages, releases, channels and tenants belong to your organisation. Only an organisation key manages them. |
| Expiry | 1 to 365 days, 90 by default. |
| Source ranges | Optional address ranges in CIDR form. Hangar refuses a request from another address with `key_source_refused`. |
| Spend limit | Optional: the most that the jobs of the key may spend, in Australian cents including GST. When a key reaches the limit, Hangar starts no job for the key (`key_spend_limit_reached`). A job that the key starts ends when the rest of the limit runs out. |

You can have two active keys for each scope. This lets you rotate keys without a pause. Make the new key, move your servers to the new key, then revoke the old key. `hangar keys revoke` takes effect on the next request, including a request that the package is retrying. Hangar adds each create and each revoke to the record chain of your organisation. `GET /v1/keys` shows the last use of each key.

| Call | What it does |
|---|---|
| `POST /v1/keys` | Makes a key. Only owners, signed in with a passkey, can make a key. The answer contains the secret one time. |
| `GET /v1/keys` | Lists the keys. Each key shows the prefix, capabilities, scope, expiry, last use, spend limit, spend so far, jobs started and images pushed. |
| `GET /v1/keys/{id}` | Gets one key. The answer has the same fields as the list. |
| `PUT /v1/keys/{id}/spend-limit` | Sets, changes or removes the spend limit of a key: `{"spend_limit": 20000}` in cents, or `null`. Only owners, signed in with a passkey, can change a limit. A key cannot change its own limit. |
| `POST /v1/keys/{id}/revoke` | Revokes a key at once. |

The package reads the key id from `HANGAR_KEY_ID`. The package reads the secret from the file that `HANGAR_KEY_FILE` names. The file must have mode 0600. `hangar keys create --save FILE` writes the file.

## 3. Tenants

A tenant is one of your customers. Hangar stores the jobs, sessions, uploads and usage of each tenant separately. The return of each job shows the tenant.

```python
tenant = client.tenants.create(reference="harbour-17", display_name="Harbour Water")
print(tenant["id"])   # tnt_0192f3a1c7d84e6b9a0c5d2e1f4b7a93
```

| Field | What it holds |
|---|---|
| `id` | `tnt_` and 32 hexadecimal digits. |
| `reference` | Your own reference for the customer, 1 to 128 characters, unique among your tenants. |
| `display_name` | The name that the returns and the usage report show, 1 to 100 characters. |
| `abn` | The ABN of the customer, 11 digits, or none. |
| `state` | `active`, `suspended` or `closed`. |
| `policy` | The policy of the tenant: the destinations that the tenant can reach and the release limits. The tenant policy can narrow the policy of your account. The tenant policy never widens the policy of your account. |

When you suspend a tenant, Hangar refuses new jobs and sessions for the tenant with `tenant_suspended`. Hangar also revokes the session tokens of the tenant. Running attempts finish in their window. When you resume the tenant, the suspension ends. When you close a tenant, Hangar cancels the jobs of the tenant that have not ended, stops the sessions, and revokes the session tokens and the request keys of the tenant. Hangar also schedules the data of the tenant for deletion when the retention period ends.

Hangar stores the objects of each tenant under that tenant. Hangar encrypts the objects with a tenant key that the key of your organisation wraps. Content addressing (naming an object by its digest) works for each tenant separately. If you upload the same bytes for two tenants, Hangar stores two objects. This stops a tenant from learning what another tenant has. You cannot use an artefact that you uploaded for one tenant as an input for another tenant. A tenant key cannot read an object of another tenant. In both cases, Hangar answers not found, as Hangar does for bytes that Hangar does not store (`data_not_found` for an input, `object_not_found` for a read).

Tenant routes: `POST /v1/tenants`, `GET /v1/tenants`, `GET /v1/tenants/{id}`, `PUT /v1/tenants/{id}/policy`, and `POST /v1/tenants/{id}/suspend`, `/resume` and `/close`.

## 4. The five calls

```python
from hangar_sdk import Client

client = Client.from_env()
```

`Client(endpoint, key_id, secret, *, tenant=None, ca_file=None, timeout=30.0)` makes a client. `Client.from_env()` reads `HANGAR_KEY_ID`, `HANGAR_KEY_FILE`, `HANGAR_TENANT` and `HANGAR_CA_FILE`, and sends requests to the API of the cell the key names, or to `HANGAR_API` when it is set. When a cell answers `wrong_cell`, the client sends the request once to the cell its key names. `client.for_tenant(tnt)` makes a client for one tenant. `client.instant.start(...)` and `client.execute(...)` take `region_policy` (one of the five settings) or `region` (`sydney` or `melbourne`): the job's own Region setting, refused with `region_policy_refused` when the organisation's setting is an only setting or is locked and this one differs. A job placed in the other Region runs there as a visiting job, and its start answer carries `visit`: `client.visits.list()`, `get(id)`, `stop(id)` and `token(id)` work in the home cell, and the token reads the job in its own cell as `Authorization: Hangar-Token hgj_<cell>_...`.

| Call | What it does |
|---|---|
| Sessions | `client.sessions.start(...)` starts a CPU session. `run(id, argv)` runs a command in the session. `output(id, command_id, after=0)` reads the output of a command from an offset. `attach`, `stop` and `certificate` reattach to the session, end the session and get its certificate. |
| Execute | `client.execute(release=..., or package=... and channel=..., inputs=..., pool=None, narrowing=None, client_ref=None)` starts a job of a release. If you give no pool, the default pool of the endpoint runs the release. On the hosted service, the default pool is `hosted-cpu-syd`. In a customer runtime, the default pool is the pool of the runtime. |
| Inspect | `client.inspect(job_id)` gets the job. `client.events(job_id, after=0)` streams the events of the job. `client.wait(job_id)` waits until the job returns. `client.cancel(job_id)` cancels the job. |
| Retrieve | `client.retrieve(job_id, dest)` downloads the return and every artefact. The call checks the digest of each artefact against the return. |
| Verify | `client.verify(certificate)` checks the signatures of the return with the `hangar` CLI, and reports every check. |

A front end or a browser of a tenant follows one job with a job token, not with a request key. `client.job_token(job_id, ["read", "stream"])` returns `{"token": "hgm_...", "expires": ...}` one time. The browser sends the token as `Authorization: Hangar-Token hgm_...` to the routes of that job only. The `read` scope allows the job, the return and the list of artefacts. The `stream` scope allows the events. The `cancel` scope allows the browser to cancel the job. A token lasts at most 12 hours, and never longer than the envelope of the job. A token stops working in these cases: the job is cancelled, the tenant is suspended or closed, or the key that made the token is revoked. A stopped token stays stopped. A token cannot make another token and cannot start a job. Any other route answers `job_token_scope`. A browser on another origin follows the job through the web service of the vendor. That web service sends the token for the browser.

The same client also covers uploads, tenants and usage. `client.uploads.put_file(path)` and `put_bytes(data)` return the `sha384:` label of the object. The package returns the label only when Hangar has stored the object, so a job never names data that is not stored yet. After the last chunk, the package asks Hangar once to complete the upload, with the header `Prefer: respond-async`. Hangar checks the digest of every chunk, then joins the chunks into one object. The package reads the state of the check with `GET /v1/uploads/{id}` 2 seconds after the request. The wait between two reads grows to 15 seconds. When the check fails, the package raises the error of the check: `upload_digest_mismatch` when the file changed during the upload, or `upload_completion_failed` when Hangar could not finish storing the upload. After 6 hours, the package stops the wait and raises `upload_completing`, an `Unavailable` error. The `detail` of the error has the upload id. `client.uploads.status(upload)` reads the state of the check again. `client.tenants` manages tenants. `client.usage(month=...)` reads usage. `client.versions()` reads `GET /v1/versions`.

### A worked example, from upload to verify

```python
from hangar_sdk import Client

client = Client.from_env()
harbour = client.for_tenant("tnt_0192f3a1c7d84e6b9a0c5d2e1f4b7a93")

# 1. Upload the input. The package sends it in chunks and returns its label.
repository = harbour.uploads.put_file("repository.tar")

# 2. Start a job of the release on the channel stable.
job = harbour.execute(
    package="hangar_patch_agent",
    channel="stable",
    inputs={"repository": {"artefact": repository}},
    client_ref="harbour-17/2026-10-23/1",
)

# 3. Follow the job. The stream reconnects by itself and never repeats an event.
for event in harbour.events(job.id):
    if event["kind"] == "connection":
        print(event["destination"], event["outcome"])

# 4. Retrieve the artefacts and the return, each checked by digest.
job = harbour.wait(job.id)
got = harbour.retrieve(job.id, "out/")
print(job.outcome, [a.name for a in got.artefacts])

# 5. Verify the return offline.
check = harbour.verify(got.return_path)
print(check.valid)
```

`retrieve` downloads each artefact through `GET /v1/objects/{digest}/content`. This route returns the plain bytes over TLS. `retrieve` compares each artefact with the digest in the return. If a digest differs, the call stops with an error. Then `retrieve` saves nothing under the name of the artefact.

`verify` runs `hangar verify --json` when the `hangar` command line is on the search path. `verify` returns the report of that command: `valid`, each check with its result, and the facts. The signature checks need ML-DSA-87 and P-256, and the `hangar` command line provides both. If the `hangar` command line is not installed, `verify` reports each signature check as `not_run` and reports `valid` as false. `hangar_sdk.returns.facts(data)` decodes the facts of a return from the canonical CBOR of the return.

### Sessions

```python
session = harbour.sessions.start(shape="c", vcpus=2, mode="commands", allowlist=["pypi.org"])
command = harbour.sessions.run(session["id"], ["python3", "-c", "print('ready')"])
for pipe, chunk in harbour.sessions.output(session["id"], command["command_id"]):
    print(pipe, chunk.decode())
harbour.sessions.stop(session["id"])
```

Sessions use the CPU session routes that the page Sandbox sessions describes. The client sets the tenant.

### A GPU now

```python
client = hangar_sdk.Client.from_env()
session = client.instant.start("h100", [{"image": "trainer:v3", "command": ["python", "train.py"]}], limit_minutes=60)
job = client.instant.jobs(session.id)[0]
print(client.instant.get(session.id).status, client.instant.job(job["id"])["status"])
client.instant.stop(session.id)
```

A key with `jobs` starts a GPU job now, with no booking, as `hangar run --gpu` does. For `h100` or `trn2`, the job uses a live block of Hangar and the price is by the started minute. For `l4` and `l4-fraction`, the job starts on demand and the price is by the second. `client.instant.get` reads a session. `client.instant.jobs` lists the jobs of a session. `client.instant.job` and `client.instant.logs` read one job. `client.instant.stop` stops a session. A key spends credit only on the sessions that the key starts, inside the limits of your organisation. The session and its jobs show the key in `started_by`. These jobs belong to your organisation, so Hangar refuses a client made for a tenant with `tenant_mismatch`.

When a key has a spend limit, `ends_by` of a new session is the time when the rest of the limit runs out. When a key reaches the limit, the call raises `LimitReached` with the code `key_spend_limit_reached`. The detail of the error has `limit_cents`, `spent_cents` and `held_cents`.

### Images

```python
client = hangar_sdk.Client.from_env()
pull = client.images.pull("docker.io/library/python@sha256:<64 hex>")
print(pull["status"], pull["pushed_by"])
print(client.images.resolve("trainer:v1")["digest"])
```

A key with `uploads` can use three calls. `client.images.pull` registers a one-time pull of a public image by its digest. `client.images.list` lists the images. `client.images.resolve` finds the image for a name and tag. A key with `jobs` can list and resolve too. To push a local image, run `hangar upload --image ARCHIVE --name NAME:TAG` with the same key. `pushed_by` shows who last pushed, registered or tagged each image. The registry belongs to your organisation, so Hangar refuses a client made for a tenant with `tenant_mismatch`.

## 5. Signing

Each request has three headers:

```
Authorization: Hangar-Key hgk_syd_7c41e0b3d9f2a81c5e604b2d
Hangar-Signature: t=1792900000,n=<64 hex>,s=<96 hex>
Hangar-Tenant: tnt_0192f3a1c7d84e6b9a0c5d2e1f4b7a93
```

`s` is HMAC-SHA-384 under the secret of the key. The message has seven lines joined by a line feed: `hangar-request-v1`, the method, the path and query exactly as sent, `t`, `n`, the SHA-384 of the body in lower-case hexadecimal, and the tenant id or `-`. Hangar accepts a signature only when the time in the signature is within 60 seconds of the clock of Hangar. Hangar accepts each nonce one time. A tenant key must send `Hangar-Tenant`, and the header must contain the tenant of the key. The package also sends `Hangar-SDK: python/<version>`.

The page "The Hangar-Signature header" (`docs/api/hangar-signature.md`) gives the full message and test vectors. You can write a client in another language from that page alone.

## 6. Retries, Idempotency-Key and client_ref

Every call that changes state sends an `Idempotency-Key`. The key is a version 4 UUID. The package makes one key for each call, and sends the same key on each retry. Hangar returns the first answer and does the work one time. Hangar refuses the same key with a different body with `idempotency_key_reused`.

The package retries a connection failure and a code marked `retryable` in the catalogue. The package retries up to five times, with full-jitter backoff (a random wait) from 0.5 to 30 seconds. The package obeys `Retry-After`. The package never retries any other code.

A job also has your `client_ref`. The `client_ref` is unique in your organisation and tenant. The `client_ref` lasts longer than the 24-hour idempotency store. If your process restarts after it sent a job, the process finds the job and does not start a second job. The same `client_ref` and body return the existing job. Hangar refuses a different body with `client_ref_conflict`.

Streams resume by offset. `client.events` remembers the last offset that `client.events` read. After each answer and after a dropped connection, `client.events` calls again and reads on from that offset. There is no gap and no duplicate. When the job has returned, `client.events` reads the end line, and the stream stops. Command output in a session resumes in the same way with `client.sessions.output(session, command, after=offset)`. `client.sessions.attach` returns the state of the session and the offsets of each command. A second client can then continue where the first client stopped.

## 7. Errors

Every error has three parts from the catalogue in `docs/api/errors.json`: what failed, the next step, and what to do if the next step fails. Each entry has `code`, `status`, `sentence`, `next_step`, `if_that_fails` and `retryable`. The package raises a `HangarError`. The error has the code, the HTTP status, the sentence, the next step, `if_that_fails` and the request id. The error is a subclass that groups the codes:

| Class | For |
|---|---|
| `AuthError` | A missing, stale, replayed or invalid signature, and an expired or revoked key. |
| `NotFound` | A package, release, job, tenant, key or pool that is not in your organisation. |
| `Conflict` | A state that does not allow the call, such as a held channel or a job that has returned. |
| `LimitReached` | A limit, such as two active keys for one scope, or a key at its spend limit (`key_spend_limit_reached`). |
| `Refused` | A call that TALOS Warrant or a policy refuses, such as `envelope_denied`, `widening_not_approved` or `input_contract`. |
| `Unavailable` | A retryable code that stayed after the retries of the package. |

A code from a newer Hangar that this version of the package does not know is a plain `HangarError`. The error has the sentence from the server.

## 8. The compatibility promise

- Within `/v1`, Hangar removes, renames and retypes no route or field. Every addition is optional.
- A deprecation has 12 months of notice.
- Every `1.x` version of the package works with every Hangar runtime release in support.
- A runtime release raises the minimum version of the package only with 12 months of notice, or at once to close a security defect. Hangar refuses a version below the minimum with `sdk_too_old`. The detail of the error shows the minimum version.
- Support lasts until the end-of-life date, 5 November 2031.

`GET /v1/versions` gives the API version, the runtime release, the certificate format, the minimum and current versions of the package, and the digest of the published release key set. For each new runtime, the check runs the tests of the previous release of the package.

## 9. Usage

```
$ hangar credit usage --month 2026-10
October 2026, TALOS Defence Pty Ltd
tnt_0192f3a1  Harbour Water   12 jobs  3 sessions  4.2 vCPU-hours  A$75.00
tnt_0192f3c4  Northern Ports   4 jobs  0 sessions  1.1 vCPU-hours  A$20.00
Total                         16 jobs  3 sessions  5.3 vCPU-hours  A$95.00
```

`GET /v1/usage?month=YYYY-MM&tenant=` gives the metered use of each tenant for a month. The report has jobs of a release, sessions, vCPU-seconds, the amount charged in cents including GST, and the bytes stored. Use this report to bill your own customers. The figures of a tenant appear only in the report of the vendor of that tenant. Hangar meters usage in jobs, compute and storage.
