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 formrun.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-counterfolder into the uploader. The form builder makes both Fields frominterface.json. - •Press Test Run, fill in the form, and read
result.txtin 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.pyfrom the project root, so relative paths such asoutput/result.txtwork. 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,.eggsand*.egg-info. - •Virtual environments: any folder holding
pyvenv.cfgorconda-meta. Packages come fromrequirements.txtinstead. - •A `.env` at the root: its variables become your Widget's Environment variables, so
os.environandload_dotenv()keep working, but reading the file does not. A name already set on Abyss wins..env.*files are left out unread, and a.envdeeper 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.
| 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:
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.pyandrequirements.txtat 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 torequirements.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 abysshubpip install abysshub2. 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:
/v1sends 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);from abysshub import Abyss
abyss = Abyss()
run = abyss.run("widget_1666", {"text": "Hello"})
print(run.result)curl https://api.abysshub.com/v1/widgets/widget_1666/runs \
-H "Authorization: Bearer $ABYSS_API_KEY" \
-F text=Hellorun() 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
httpsURL, fetched at the press before anything is charged. One it cannot fetch is refused withinput_unreachable. - •
-F field=@pathin 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);from abysshub import Abyss, file
abyss = Abyss()
run = abyss.run("widget_1666", {
"text": "Hello",
"file": file("notes.txt"),
})
print(run.result)curl https://api.abysshub.com/v1/widgets/widget_1666/runs \
-H "Authorization: Bearer $ABYSS_API_KEY" \
-F text=Hello \
-F file=@notes.txtA 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:
- •
resultis the JSON your Widget wrote tooutput/output.json, ornullif it wrote none. - •
output_fileslists every file it wrote, each with itspathinsideoutput/, itssizein bytes, itscontent_typeand aurlto download it. - •
priceis 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"]paths = run.save("output") # [Path("output/echo.txt"), Path("output/output.json")]{
"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.
// 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);
}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){
"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
errorobject above, with its HTTP status. - •A failed run was refunded, and the error's
runholds it. With cURL it is the run withstatus: "failed": the press answers200before the ending is known, so readstatus, never the HTTP status. - •
paramnames the Field or key at fault, anddoc_urllinks to these docs, where each code has its row below. Quoterequest_idto support; every response carries it as aRequest-Idheader. - •The library adds
timeout,connection_errorandunexpected_response.
| Code | Answered with | What to do |
|---|---|---|
invalid_api_key | 401 | Send 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_found | 404 | Check 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_request | 422 | Fix 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_input | 422 | Make 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_unknown | 422 | Name one of the Widget's file Fields; param is the name you sent. |
held_input_type_refused | 422 | Send a file of a type the Field param names takes. |
held_input_too_large | 422 | Send a smaller file for the Field param names. |
held_input_not_yours | 422 | Send the id of your own upload for this Widget (POST /v1/widgets/{id}/uploads) in the Field param names. |
held_input_missing | 422 | Upload the file again and send its new id in the Field param names. |
held_input_too_many | 422 | Send fewer files in the Field param names. |
input_unreachable | 422 | Send 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_reused | 422 | Send a new Idempotency-Key for a new press; a retry must repeat the first request exactly. |
idempotency_key_in_use | 409 | Wait the Retry-After seconds, then send the same request with the same Idempotency-Key again to get its run. |
run_files_waking | 409 | Wait 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_funds | 402 | Top up your wallet by at least shortfall Ƀ, then press again. |
payments_stopped | 403 | Ask 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_stopped | 403 | An 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_reached | 400 | Raise 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_max | 400 | Check the new price (GET /v1/widgets/{id}), then press again with a higher max_price or none. |
rate_limited | 429 | Wait the seconds in the Retry-After header, then send the request again. |
server_error | 500 | Try again; if it keeps happening, quote the request_id to support. |
widget_fault | a failed run | The 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_fault | a failed run | The platform's machinery failed, not the Widget; press again. |
budget_exceeded | a failed run | The 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);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)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` (
maxPricein JavaScript) is the most you will pay for this run. If the Widget'spricerose above it, the press is refused withprice_above_max. - •Safe retries. The library sends an
Idempotency-Keywith 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 isidempotency_key_reused. Keys are kept for 24 hours.
const run = await abyss.run("widget_1666", { text: "Hello" }, { maxPrice: 5, idempotencyKey: "order-1042" });run = abyss.run("widget_1666", {"text": "Hello"}, max_price=5, idempotency_key="order-1042")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 is429 rate_limitedwithRetry-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 withlimit(1 to 100) andstarting_after, a run'sid. - •
GET /v1/widgets/{id}: a Widget before you press it: itspriceand itsinput_schema, in JSON Schema. - •
POST /v1/widgets/{id}/uploads: for a local file, with{"field", "filename"}. Send every entry of theupload.fieldsit answers, then the file asfile, toupload.url, and put itsidin 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 onlyinputis needed."wait": false, or a busy API, answers202at once with the runqueued. - •A form (
multipart/form-data), one part per Field: a number as250000, a date as2026-10-05. It carries input only, somax_priceandwaitneed 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.