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 follows it. Byssium and Prime are in the FAQ, and a coding agent can start from /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.

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

run.py:

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:

[
  { "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).

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:

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.

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.

FieldtypeYour code receivesKeys
Texttext-fieldthe textminLength, maxLength, pattern
Text Areatext-areathe textminLength, maxLength, pattern
Numbernumber-fieldthe number, as text: use int() or float()min, max
Radioradio-groupthe chosen optionoptions (required)
Checkboxcheckbox-groupthe chosen options, as a JSON listoptions (required), minItems, maxItems
Selectselectthe chosen optionoptions (required)
Datedate-fieldthe datefromYear, toYear
Filefilethe file's path, or with multiple, a JSON list of pathsmimes (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.

LimitValuePast it
MachineUbuntu 24.04, Python 3.12, 2 vCPUs, 4 GB RAM
Time30 minutes a runThe run is stopped and the Runner refunded.
Network2 GB sent a runThe run is stopped and the Runner refunded.
One output file512 MBNothing uploads, and the run fails as your Widget's fault.
All outputs2 GB a runNothing 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:

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).
  • 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

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 and Python. Other languages send plain HTTP: see the HTTP reference, and every example's last tab is cURL. A coding agent can read this whole page as plain markdown.

Quickstart

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

1. Install the library. cURL needs nothing installed.

npm 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:

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:

// index.mjs
import Abyss from "abysshub";

const abyss = new Abyss();

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

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.
// 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);

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().

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

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.

// 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);
}
  • 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.
CodeAnswered withWhat to do
invalid_api_key401Send a live API Key as Authorization: Bearer abyss_sk_…; a missing, unknown or revoked key, or a website sign-in token, is refused, so create a new key in your Cave if this one is gone.
not_found404Check the id and the path: this key can't see that Widget or run, and a run is readable only by the key that made it.
invalid_request422Fix the part param names (null when the body is not a JSON object): a press takes only input, max_price and wait (a boolean), an upload only field, filename and size, the run list only limit and starting_after, and an Idempotency-Key is 1 to 255 visible ASCII characters.
invalid_input422Make the input match the Widget's input_schema (GET /v1/widgets/{id}), starting with the Field param names; the message lists every problem.
held_input_field_unknown422Name one of the Widget's file Fields; param is the name you sent.
held_input_type_refused422Send a file of a type the Field param names takes.
held_input_too_large422Send a smaller file for the Field param names.
held_input_not_yours422Send the id of your own upload for this Widget (POST /v1/widgets/{id}/uploads) in the Field param names.
held_input_missing422Upload the file again and send its new id in the Field param names.
held_input_too_many422Send fewer files in the Field param names.
input_unreachable422Send an https URL on a public address that answers with the file, or upload the file and send its id, in the Field param names.
idempotency_key_reused422Send a new Idempotency-Key for a new press; a retry must repeat the first request exactly.
idempotency_key_in_use409Wait the Retry-After seconds, then send the same request with the same Idempotency-Key again to get its run.
run_files_waking409Wait the Retry-After seconds, then press again: the upload in the Field param names is an input of one of your runs whose files are asleep, and that run is waking now (POST /v1/runs/{id}/wake).
insufficient_funds402Top up your wallet by at least shortfall Ƀ, then press again.
payments_stopped403Ask us to look from your Cave, whose address the message names: your bank disputed a payment, and paid runs stay paused until it's settled. Retrying will not help.
account_stopped403An Admin has stopped your account; ask us about it by writing to support@abysshub.com. Your keys work again once the stop is lifted. Retrying will not help.
spend_cap_reached400Raise or remove this key's Spend Cap in your Cave, or wait for the next calendar month (UTC); retrying sooner will not help.
price_above_max400Check the new price (GET /v1/widgets/{id}), then press again with a higher max_price or none.
rate_limited429Wait the seconds in the Retry-After header, then send the request again.
server_error500Try again; if it keeps happening, quote the request_id to support.
widget_faulta failed runThe Widget's own code crashed or wrote no output; check your input, because pressing again with the same one will likely fail the same way.
platform_faulta failed runThe platform's machinery failed, not the Widget; press again.
budget_exceededa failed runThe run reached the Run Budget and was stopped; press again only with a smaller input.

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.

// 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);

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.
const run = await abyss.run("widget_1666", { text: "Hello" }, { maxPrice: 5, idempotencyKey: "order-1042" });

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, 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.