# Publish a Widget

Abyss is the execution layer for AI-built tools. Publish your Python project as a **Widget**, and Abyss gives it an input form, a share link and a price per run, keeps your API keys, and runs it for the people you share it with.

This part takes a folder of Python to a published Widget: an example first, then the reference. [Call Abyss from your code](#api) follows it. Byssium and Prime are in the [FAQ](/faq), and a coding agent can start from [/llms.txt](/llms.txt).

---

## Your first Widget in five minutes

This is a whole Widget: it greets the person running it and counts the lines in a file they pick.

```text
line-counter/
├── run.py
├── requirements.txt      # empty: nothing to install
└── interface.json        # the input form
```

run.py:

```python
import json
import os

name = os.environ.get("user_name", "stranger")
with open(os.environ["input_file"], encoding="utf-8") as f:
    lines = sum(1 for _ in f)

with open("output/result.txt", "w") as f:
    f.write(f"Hello {name}! Your file has {lines} lines.\n")
with open("output/output.json", "w") as f:
    json.dump({"lines": lines}, f)
```

interface.json:

```json
[
  { "type": "text-field", "name": "user_name", "label": "Your name", "maxLength": 50 },
  { "type": "file", "name": "input_file", "label": "Text file to count", "mimes": ["txt"] }
]
```

- Press **Publish** in the navbar and drag the `line-counter` folder into the uploader. The form builder makes both Fields from `interface.json`.
- Press **Test Run**, fill in the form, and read `result.txt` in the outputs.
- Add a title, a thumbnail and a tag, set a price, and press **Publish**. Your Widget is live.

---

## The project

A Widget is an ordinary Python project with two required files at its root.

- **run.py**: Abyss runs `python3 run.py` from the project root, so relative paths such as `output/result.txt` work. Other files and folders may sit beside it.
- **requirements.txt**: every package your code imports, one per line. It must exist, even empty.
- **requirements.system**, optional: apt packages a library needs, such as `poppler-utils`.
- **interface.json**, optional: your form, read when you upload (see [Field types](#field-types)).

You upload the project as a folder. Its only budget is total size, which the uploader shows. Packages are installed once, when your Widget is built, so a run can't install any. Your code does not run as root, and writes only inside the project folder and `/tmp`.

---

## What isn't uploaded

These rules say what the uploader leaves out of your folder, which it lists under the file tree.

- **By name, anywhere:** `.git`, `.svn`, `.hg`, `.idea`, `.vscode`, `.DS_Store`, `node_modules`, `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `.tox`, `.nox`, `.ipynb_checkpoints`, `.venv`, `venv`, `.eggs` and `*.egg-info`.
- **Virtual environments:** any folder holding `pyvenv.cfg` or `conda-meta`. Packages come from `requirements.txt` instead.
- **A `.env` at the root:** its variables become your Widget's Environment variables, so `os.environ` and `load_dotenv()` keep working, but reading the file does not. A name already set on Abyss wins. `.env.*` files are left out unread, and a `.env` deeper in the tree is content.

Everything else travels, dotted names included.

---

## Inputs

This section is how your code reads what the Runner gave it. **Every Field becomes an environment variable named exactly after the Field's name**, and every value arrives as a string:

```python
user_name = os.environ.get("user_name")
```

A File Field holds the file's path, relative to the project root. With **Allow multiple files**, the files land in a folder named after the Field, and the variable is a JSON list of their paths, in the Runner's order.

```python
for path in json.loads(os.environ["photos"]):  # "photos/1-a.png", "photos/2-a.png", "photos/b.png"
    with open(path, "rb") as f:
        data = f.read()
```

What you `print()` appears in the run logs (the last 1,000 lines), so a Test Run shows what your Widget receives.

---

## Field types

This table is every Field the form can hold, what your code receives, and its keys.

| Field | `type` | Your code receives | Keys |
|---|---|---|---|
| Text | `text-field` | the text | `minLength`, `maxLength`, `pattern` |
| Text Area | `text-area` | the text | `minLength`, `maxLength`, `pattern` |
| Number | `number-field` | the number, as text: use `int()` or `float()` | `min`, `max` |
| Radio | `radio-group` | the chosen option | `options` (required) |
| Checkbox | `checkbox-group` | the chosen options, as a JSON list | `options` (required), `minItems`, `maxItems` |
| Select | `select` | the chosen option | `options` (required) |
| Date | `date-field` | the date | `fromYear`, `toYear` |
| File | `file` | the file's path, or with `multiple`, a JSON list of paths | `mimes` (empty takes any file), `maxFileSizeMB`, `multiple`, `maxFiles` |

Every Field also takes `name` (a letter, then letters, digits and underscores), `label`, `isRequired` (`true` unless set), `placeholder` and `default`. The builder shows your form's `interface.json` at any time, and one in your upload adds its Fields without touching those you made by hand.

---

## Outputs

This section is how your Widget hands back its work. It writes everything into the `output/` folder, which Abyss creates, and must write **at least one file** there, or the run fails. JSON in `output/output.json` is the run's Result, which code calling Abyss reads directly. An HTML output opens as its own small website, where relative links to your other outputs load.

Whatever a Widget writes to its `output/` folder, its Result `output.json` included, is shown to whoever runs it, and on Shared and Example Runs to anyone, signed out, including any value from the Creator's Environment variables.

---

## Run limits

This table is what a run gets, and what happens past it.

| Limit | Value | Past it |
|---|---|---|
| Machine | Ubuntu 24.04, Python 3.12, 2 vCPUs, 4 GB RAM | |
| Time | 30 minutes a run | The run is stopped and the Runner refunded. |
| Network | 2 GB sent a run | The run is stopped and the Runner refunded. |
| One output file | 512 MB | Nothing uploads, and the run fails as your Widget's fault. |
| All outputs | 2 GB a run | Nothing uploads, and the run fails as your Widget's fault. |

A run stopped at the time or network limit is not your Widget's fault, but three in a row freeze it, as do three runs in a row that fail because of your code. A Frozen Widget leaves the Catalog, and its page shows you why. Your own succeeded run of it, or a new Publish, brings it back.

---

## Environment variables

This section is where your API keys go. A Widget can call any AI model Python reaches; when it needs a key, add it to your Widget's **Environment variables** and read it like any other variable:

```python
api_key = os.environ.get("OPENAI_API_KEY")
```

One set in your Cave settings reaches all your Widgets, and one set on a Widget wins over it. **Never write a key into your code.**

---

## Test, publish, price

This section is the path from an upload to a live Widget, and what it earns.

- **Test Run.** It is free, and publishing needs one that succeeded.
- **Publish.** It also needs a title, a thumbnail and a tag. Your Test Run becomes the Widget's **Example Run**, Shared with everyone who can see the Widget, so test with nothing personal.
- **Price.** **0 to 1,000 Byssium a run**; 0 is free. A paid Widget can give each person up to 10 **Free Runs**. Your own runs are free.
- **Earnings.** Someone else's succeeded paid run moves its price into your wallet as Earned Byssium. A Free Run pays nothing, and a failed run is refunded to its Runner.

To change a live Widget, use the three-dot menu on its page. **Edit listing** saves at once. **Edit project** changes the files and form, which go live at your next Test Run and Publish.

---

## How people run your Widget

This section is what your client, or anyone who opens your Widget, sees.

Its page shows your form, the price, your Example Run and a **Run** button. A Runner short of Byssium tops up first, then views and downloads the outputs. Prime subscribers pay your price like everyone else, and a run is its Runner's alone unless they share it.

---

## Your code, keys and data

This section is who can see what you put on Abyss.

- **Your code.** Runners get the results, not your Source. Only you and Abyss's admins see it.
- **Your Environment variables.** Abyss sends their values only to you, masked until you reveal them. Your code gets them on every run, so what it writes out is shown (see [Outputs](#outputs)).
- **Your network.** Each Widget runs on its own private network, which no other Widget's runs can reach.
- **Input files.** A file picked but never run is deleted a day after upload. A file used in a run stays with it, for Run again.

---

## Troubleshooting

This list maps each common error to its fix.

- **"Project must include run.py in the root directory"** or **"Widget project files are missing"**: put `run.py` and `requirements.txt` at the root.
- **"Error encountered while executing run.py"**: your code crashed; read the traceback in the run logs.
- **ModuleNotFoundError**: add the package to `requirements.txt`, or its native part to `requirements.system`.
- **An input arrives as None**: the name in your code doesn't match the Field's `name`.
- **"Permission denied"**: write to `output/` or `/tmp`.

---

# Call Abyss from your code {#api}

This part runs any Widget your code can see: your own, one in the Catalog, or an unlisted one whose private link you hold. One call sends the input and gets back the run's Result and files, whatever the Widget and however long it runs.

JavaScript and Python use the `abysshub` library, whose README is the full reference, for [JavaScript](https://github.com/Abysshub/abysshub-sdk/tree/main/js#readme) and [Python](https://github.com/Abysshub/abysshub-sdk/tree/main/python#readme). Other languages send plain HTTP: see [the HTTP reference](#the-http-reference), and every example's last tab is cURL. A coding agent can read this whole page as [plain markdown](/docs.md).

---

## Quickstart

This section takes you from nothing to a first run in three steps.

**1. Install** the library. cURL needs nothing installed.

```bash [JavaScript]
npm install abysshub
```

```bash [Python]
pip install abysshub
```

**2. Make a key.** Create an API Key in your **Cave**. The full key, `abyss_sk_` and its secret, is shown **once**: Abyss keeps no copy to show again. Put it in your environment, where the library reads it:

```bash
export ABYSS_API_KEY="abyss_sk_…"
```

- **A key acts as you.** Its runs are paid from your Byssium wallet and private to you. **A key reads only the runs it made**; you see every key's runs on the website.
- **Keep it on a server**, and **never in a browser**: `/v1` sends no CORS headers, so a page holding a key has leaked it.
- **Limit a key** to the Widgets you pick, in your Cave, so one client's key can't run another's Widget. A Widget outside its list answers `404 not_found`.
- **A leaked key gets a New Secret** in your Cave. The old secret stops at once; the key keeps its name, Spend Cap and runs.
- **Revoke a key** in your Cave and its next call is refused. Its runs stay yours, on the website.

**3. Run** a Widget. This one is free and echoes its text:

```javascript [JavaScript]
// index.mjs
import Abyss from "abysshub";

const abyss = new Abyss();

const run = await abyss.run("widget_1666", { text: "Hello" });
console.log(run.result);
```

```python [Python]
from abysshub import Abyss

abyss = Abyss()

run = abyss.run("widget_1666", {"text": "Hello"})
print(run.result)
```

```bash [cURL]
curl https://api.abysshub.com/v1/widgets/widget_1666/runs \
  -H "Authorization: Bearer $ABYSS_API_KEY" \
  -F text=Hello
```

`run()` presses the Widget, waits for the run to end however long it takes, and returns it. **Your first 100 API runs carry no Call Price**, so a free Widget costs nothing while you build.

A Widget's id is `widget_` and its number. Each Widget's page has an **API** button with its own code, and its markdown page, `/widgets/{number}.md`, lists its Fields, which the input takes by name.

---

## Files

This section is how to send a file Field, which takes a file in one of three forms:

- `file("…")`: a file on your machine, which the library uploads first.
- A public `https` URL, fetched at the press before anything is charged. One it cannot fetch is refused with `input_unreachable`.
- `-F field=@path` in cURL, sent in the press itself.

```javascript [JavaScript]
// index.mjs
import Abyss, { file } from "abysshub";

const abyss = new Abyss();

const run = await abyss.run("widget_1666", {
  text: "Hello",
  file: file("notes.txt"),
});
console.log(run.result);
```

```python [Python]
from abysshub import Abyss, file

abyss = Abyss()

run = abyss.run("widget_1666", {
    "text": "Hello",
    "file": file("notes.txt"),
})
print(run.result)
```

```bash [cURL]
curl https://api.abysshub.com/v1/widgets/widget_1666/runs \
  -H "Authorization: Bearer $ABYSS_API_KEY" \
  -F text=Hello \
  -F file=@notes.txt
```

A Field that takes several files takes a list, which may mix files and URLs; in cURL, repeat the name with `[]`: `-F "photos[]=@a.png"`.

---

## What comes back

This section is the run `run()` returns once it **succeeded**:

- `result` is the JSON your Widget wrote to `output/output.json`, or `null` if it wrote none.
- `output_files` lists every file it wrote, each with its `path` inside `output/`, its `size` in bytes, its `content_type` and a `url` to download it.
- `price` is what the run cost you, in Byssium.

`run.save()` writes every output file into a folder and returns the paths. Each of `output_files` also has `save()` and `read()`.

```javascript [JavaScript]
const paths = await run.save("output"); // ["output/echo.txt", "output/output.json"]
```

```python [Python]
paths = run.save("output")  # [Path("output/echo.txt"), Path("output/output.json")]
```

```json [cURL]
{
  "id": "run_8fK2mQx7Lp3Rz9Tb4Wc6Yd1N",
  "widget": "widget_1666",
  "status": "succeeded",
  "price": 0,
  "result": {"text": "Hello", "file": null},
  "output_files": [
    {"path": "echo.txt", "size": 6, "content_type": "text/plain", "url": "https://…"},
    {"path": "output.json", "size": 31, "content_type": "application/json", "url": "https://…"}
  ],
  "error": null,
  "created_at": "2026-10-06T12:00:00Z",
  "started_at": "2026-10-06T12:00:02Z",
  "ended_at": "2026-10-06T12:00:05Z"
}
```

A file's `url` is signed fresh on every read and expires, so **re-read the run, never store a URL.** The library refreshes it by itself.

`price` is the Widget's price for you plus the **Call Price**, 0.1 Byssium a run through the API, waived on your first 100. A Free Run, or a run of your own Widget, costs only the Call Price, and a failed run's `price` reads `0`. The sum of `price` is exactly what you spent.

---

## Errors

This section is how to tell what went wrong. A refusal, a failed run and a timeout all raise one class, `AbyssError`. Branch on its `.code`, **never on the status or the message**: the message is written for a person and may change, the code never does.

```javascript [JavaScript]
// index.mjs
import Abyss, { AbyssError } from "abysshub";

const abyss = new Abyss();

try {
  const run = await abyss.run("widget_1666", { text: "Hello" });
  console.log(run.result);
} catch (error) {
  if (!(error instanceof AbyssError)) throw error;
  console.error(error.code, error.message, error.request_id);
}
```

```python [Python]
from abysshub import Abyss, AbyssError

abyss = Abyss()

try:
    run = abyss.run("widget_1666", {"text": "Hello"})
    print(run.result)
except AbyssError as error:
    print(error.code, error.message, error.request_id)
```

```json [cURL]
{
  "error": {
    "code": "insufficient_funds",
    "message": "This press costs Ƀ12.1; your wallet holds Ƀ7. Top up Ƀ5.1, then press again. Nothing was charged.",
    "doc_url": "https://abysshub.com/docs#insufficient_funds",
    "shortfall": 5.1
  },
  "request_id": "req_…"
}
```

- **A refusal** comes at once, before anything is charged. With cURL it is the `error` object above, with its HTTP status.
- **A failed run** was refunded, and the error's `run` holds it. With cURL it is the run with `status: "failed"`: the press answers `200` before the ending is known, so read `status`, **never the HTTP status**.
- `param` names the Field or key at fault, and `doc_url` links to these docs, where each code has its row below. Quote `request_id` to support; every response carries it as a `Request-Id` header.
- The library adds `timeout`, `connection_error` and `unexpected_response`.

The codes, with their statuses and what to do about each, are listed under `components.schemas.ErrorCode` in [openapi.json](https://api.abysshub.com/v1/openapi.json).

---

## Long runs

This section is for runs that take a while. `run()` waits for any length, and when a wait is cut, by the network or after an hour, it reattaches by itself.

For code that must not block, `runs.create()` presses and returns the run `queued`. Later, or from another process, `runs.get()` with `wait` waits for its ending. `timeout`, in seconds, stops the waiting, never the run: it raises `AbyssError` with the code `timeout`.

```javascript [JavaScript]
// index.mjs
import Abyss from "abysshub";

const abyss = new Abyss();

const queued = await abyss.runs.create("widget_1666", { text: "Hello" });
console.log(queued.id, queued.status);

const run = await abyss.runs.get(queued.id, { wait: true, timeout: 600 });
console.log(run.status, run.result);
```

```python [Python]
from abysshub import Abyss

abyss = Abyss()

queued = abyss.runs.create("widget_1666", {"text": "Hello"})
print(queued.id, queued.status)

run = abyss.runs.get(queued.id, wait=True, timeout=600)
print(run.status, run.result)
```

```bash [cURL]
curl -i https://api.abysshub.com/v1/widgets/widget_1666/runs \
  -H "Authorization: Bearer $ABYSS_API_KEY" \
  -F text=Hello

# Cut off? Read the run at the Location header, with ?wait=true
curl "https://api.abysshub.com/v1/runs/run_8fK2mQx7Lp3Rz9Tb4Wc6Yd1N?wait=true" \
  -H "Authorization: Bearer $ABYSS_API_KEY"
```

`runs.get()` returns the run as it is, failed or not. A wait answers after an hour at most, so while `status` is `queued` or `running`, ask again.

With cURL, the press answers `200` and its headers at once, `Location` among them, then a space every 2 seconds, which JSON allows, then the run. If the connection is cut, or the hour passes, read the run at its `Location` with `?wait=true`. If no headers came at all, press again with the same `Idempotency-Key`.

---

## Spending safely

This section is how to cap what your code can spend.

- **A Spend Cap**, set on a key in your Cave, is the most it may spend in a calendar month (UTC). A press past it is refused with `spend_cap_reached`, and the first time in a month you get an email.
- **`max_price`** (`maxPrice` in JavaScript) is the most you will pay for this run. If the Widget's `price` rose above it, the press is refused with `price_above_max`.
- **Safe retries.** The library sends an `Idempotency-Key` with each call and reuses it on its retries, so a run is never made or charged twice. Give your own, such as an order's id, and a press sent again after a crash gets the first press's run. The same key with a different input is `idempotency_key_reused`. Keys are kept for 24 hours.

```javascript [JavaScript]
const run = await abyss.run("widget_1666", { text: "Hello" }, { maxPrice: 5, idempotencyKey: "order-1042" });
```

```python [Python]
run = abyss.run("widget_1666", {"text": "Hello"}, max_price=5, idempotency_key="order-1042")
```

```bash [cURL]
curl https://api.abysshub.com/v1/widgets/widget_1666/runs \
  -H "Authorization: Bearer $ABYSS_API_KEY" \
  -H "Idempotency-Key: order-1042" \
  -H "Content-Type: application/json" \
  -d '{"input": {"text": "Hello"}, "max_price": 5}'
```

---

## Limits and versioning

This section is how fast you may call, and what stays stable.

- **300 requests a minute** per key, across `/v1`. Over it, the answer is `429 rate_limited` with `Retry-After`, which the library waits out and retries; with cURL, do the same.
- **Runs above your base wait in line.** Your account has a base number of runs in flight, website and API together. A press above it is never refused: the run waits `queued`.

The version is the `/v1` in the path. **What you write today keeps working**: new fields, codes and optional parameters may appear, and nothing is renamed, removed or given a new meaning, so ignore what you don't know. A breaking change would be a new `/v2`. The library's minor versions only add.

---

## The HTTP reference

This section is for any language without the library. Every request goes to `https://api.abysshub.com/v1` with `Authorization: Bearer $ABYSS_API_KEY`. The exact contract is [openapi.json](https://api.abysshub.com/v1/openapi.json), served without a key.

- `POST /v1/widgets/{id}/runs`: **the press.** It runs the Widget and waits for the run to end.
- `GET /v1/runs/{id}`: a run as it is now, or with `?wait=true`, once it has ended.
- `GET /v1/runs`: the runs this key made, newest first, paged with `limit` (1 to 100) and `starting_after`, a run's `id`.
- `GET /v1/widgets/{id}`: a Widget before you press it: its `price` and its `input_schema`, in JSON Schema.
- `POST /v1/widgets/{id}/uploads`: for a local file, with `{"field", "filename"}`. Send every entry of the `upload.fields` it answers, then the file as `file`, to `upload.url`, and put its `id` in the input.
- `GET /v1/key`: the key you are sending, with its Spend Cap and this month's spend.
- `GET /v1/openapi.json`: the contract.

A Widget's `{id}` may also be an unlisted Widget's private token. A run's is `run_` and 24 characters.

**The press body** is JSON or a form.

- **JSON:** `{"input": {…}, "max_price": 5, "wait": false}`, where only `input` is needed. `"wait": false`, or a busy API, answers `202` at once with the run `queued`.
- **A form** (`multipart/form-data`), one part per Field: a number as `250000`, a date as `2026-10-05`. It carries input only, so `max_price` and `wait` need JSON.

**Headers.** A retry with the same `Idempotency-Key` and body answers like the first press, with `Idempotent-Replayed: true`. `Retry-After` comes exactly when waiting will fix the error, as with `409 idempotency_key_in_use`.

---
